Compare commits

...
8 Commits
Author SHA1 Message Date
snowspeeder 7990354a91 docs: record verified voice web production deployment 2026-10-04 10:43:08 -04:00
snowspeeder 235dd65487 feat(ios): align chat library memory and tasks with web 2026-10-04 10:41:17 -04:00
snowspeeder 8905dc2c2a fix: give realtime voice a dedicated conversation view
Rebuild & Deploy / rebuild (push) Successful in 3m7s
2026-10-04 10:35:34 -04:00
snowspeeder 257ab48f0f docs: confirm web Docker release verified in production 2026-10-04 08:38:04 -04:00
snowspeeder ea4e191400 fix: serialize SQLite close with cancelled worker transactions
Rebuild & Deploy / rebuild (push) Successful in 4m1s
2026-10-04 08:32:33 -04:00
snowspeeder 5a02b8f0c1 fix: stream tracked source into CI qualification container
Rebuild & Deploy / rebuild (push) Failing after 1m53s
2026-10-04 08:23:52 -04:00
snowspeeder 41ca590744 feat: clarify web library memory and conversation management
Rebuild & Deploy / rebuild (push) Failing after 17s
2026-10-04 07:58:16 -04:00
snowspeeder 3183ebaea7 docs: confirm monorepo publication and iOS archive 2026-10-04 07:24:49 -04:00
31 changed files with 2162 additions and 299 deletions

No files matched your search

+7 -4
View File
@@ -12,11 +12,14 @@ jobs:
- name: Qualify offline account and chat contracts before building or deploying - name: Qualify offline account and chat contracts before building or deploying
run: | run: |
# No NAS, speech SDK, model calls or legacy live-default conftest. # No NAS, speech SDK, model calls or legacy live-default conftest.
# Read-only source mount; test secrets and databases use container /tmp. # The socket-backed runner checkout path is not a host bind source.
docker run --rm --user "$(id -u):$(id -g)" \ # Stream only tracked source; secrets/runtime files are never included.
git archive HEAD | docker run --rm -i --user "$(id -u):$(id -g)" \
-e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 \ -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 \
-v "$PWD:/src:ro" -w /src python:3.12-slim-bookworm \ python:3.12-slim-bookworm sh -eu -c '
sh -eu -c ' mkdir -p /tmp/src
tar -xf - -C /tmp/src
cd /tmp/src
python -m venv /tmp/offline-tests python -m venv /tmp/offline-tests
/tmp/offline-tests/bin/python -m pip install --no-cache-dir pytest pytest-asyncio -r control/requirements.txt /tmp/offline-tests/bin/python -m pip install --no-cache-dir pytest pytest-asyncio -r control/requirements.txt
/tmp/offline-tests/bin/python scripts/generate_hope_themes.py --check /tmp/offline-tests/bin/python scripts/generate_hope_themes.py --check
+3
View File
@@ -34,6 +34,9 @@
## Git and deployment ## Git and deployment
- The Gitea Actions runner runs in a Docker container on Unraid. SSH to `[email protected]` to inspect the runner, host Docker images/containers, and deployment health; use `tea` to track workflow runs.
- The runner uses the host Docker socket: checkout paths inside the runner are not necessarily valid host bind sources. Stream tracked source (`git archive HEAD`) into qualification containers rather than binding runner `$PWD`; use host-network probes for host loopback services. Never print runtime secrets or full container environment inspections.
- `master` of this repository is the single integration branch for API/web/iOS. The old `hope-voice-ios` repository is a read-only archive after migration. - `master` of this repository is the single integration branch for API/web/iOS. The old `hope-voice-ios` repository is a read-only archive after migration.
- Preserve original iOS ancestry; do not squash away the imported history. See the migration record for original tips and lookup commands. - Preserve original iOS ancestry; do not squash away the imported history. See the migration record for original tips and lookup commands.
- Use conventional commits and explicitly stage intended files; leave unrelated local design artifacts untouched. - Use conventional commits and explicitly stage intended files; leave unrelated local design artifacts untouched.
+72
View File
@@ -1,5 +1,77 @@
# Multi-user Hermes migration — qualification and operations # Multi-user Hermes migration — qualification and operations
## Dedicated web voice session refinement — 2026-10-04 (local qualification and production follow-up)
Local browser qualification exercised the actual web app's voice start, mute,
unmute, transcript-packet handling and end lifecycle with fake LiveKit at
1440×900, 390×844, 320×568 and 844×390. No JavaScript errors or viewport overflow
were observed. The transcript owns its scroll region, session controls remain
persistent, the text composer is hidden during voice, and its draft is restored
when the session ends. Independent actual-app fake-transport screenshots passed
in light and dark themes at all four viewports after the final changes; the LED
overflow issue was fixed.
Parent-reported static checks passed again after the final changes:
`node --check web/app.js`, `node --check web/chat.js`, `git diff --check`, and
theme generator `--check`. The final seven-module offline browser suite passed:
**47 passed in 97.40s** (parent receipt `bash-1727`). Coverage includes cancellation
while connecting, token failure, microphone denial, disconnect, logout, scroll
position, selected durable-history isolation, and the HTTP-only compatibility
composer. Fake transport and simulated microphone failures are browser lifecycle
evidence, not physical audio qualification.
The local qualification above accessed no credentials, live APIs or NAS and did
not itself deploy anything. Existing dirty iOS, AGENTS and design changes were
left untouched. Physical microphone, real TTS and native qualification remain
outside this refinement.
### Authorized production follow-up
After user deployment authorization, release `8905dc2` was pushed to `master` and
[Gitea run248](https://git.srmr.xyz/snowspeeder/hope-voice-api/actions/runs/248)
succeeded, completing at `2026-10-04T14:38:47Z`. Python 3.12 backend CI passed:
**148 passed in 68.09s**, with one Starlette deprecation warning; theme checks
also passed. The 47-test offline browser receipt above remains separate evidence.
Parent SSH verification found the production voice container running and healthy,
started at `2026-10-04T14:38:23Z`, with image
`sha256:e4fb1bdbe73e44c27a8d1b68d75830f15af6b8fdaa5d5742403c4a6beb8a4175`.
The control image was unchanged (reported prefix `db756e976829`); its container
restarted at `2026-10-04T14:38:20Z`, remained running, and `/health` returned OK.
Rollback containers with suffix `20261004T143816Z` were retained stopped.
Final live verification passed (parent receipt `bash-1839`, exit 0): all seven
web entrypoint assets returned HTTP 200 and matched release `8905dc2` byte-for-byte
and by SHA256. Anonymous `GET /api/auth/me` and `POST /token` both returned 401.
Fresh mobile Chromium at 390×844 showed the login ready and visible with the
authenticated app hidden, zero JavaScript or console errors, zero unexpected
requests, and no horizontal overflow. Screenshot receipt:
[/tmp/hope-voice-live-login.png](/tmp/hope-voice-live-login.png).
The inspection client alone accepted the self-signed TLS certificate; production
TLS policy was not changed. No authenticated account-data or CRUD, live voice,
physical microphone, real TTS or native verification is claimed by this
deployment receipt.
## Web UX refinement — 2026-10-04 (production deployed)
Release `ea4e191` deployed successfully via [Gitea run244](https://git.srmr.xyz/snowspeeder/hope-voice-api/actions/runs/244)
following exact Python3.12 CI qualification:148 passed plus theme verification.
Initial runner bind failure and a real SQLite cancellation/close race were fixed,
without disabling qualification or changing protected runtime configuration.
Docker speech/web and control containers restarted with newly built images and
reported healthy/OK. All seven live web entrypoint assets matched release bytes;
anonymous auth/token remained401. Live mobile login loaded without JS errors or
overflow. Account data was not read/mutated; no authenticated CRUD, audio or native
installation claim is made. Detailed receipts are in the linked report below.
Web-only sidebar actions, Library upload/reuse and Saved drafts, scheduled-task
layout, memory revision recovery, and contextual Security/status/signout improved.
Final 43 offline browser tests passed; 85 final full-interface/internal-scroll
screenshots captured at 1440/390/320 widths with no JS or unhandled/external
requests. Independent source review findings fixed and final review approved.
See [detailed scope and evidence](docs/WEB_UX_REFINEMENT.md). Live public asset,
anonymous authorization and container-health verification is documented above;
native parity and physical audio remain outside this refinement.
## iPhone certificate onboarding follow-up — 2026-10-01 ## iPhone certificate onboarding follow-up — 2026-10-01
User reported certificate error at iOS sign-in. Current pinned native HTTPS probe User reported certificate error at iOS sign-in. Current pinned native HTTPS probe
+4
View File
@@ -103,6 +103,10 @@ class Accounts:
os.chmod(companion, 0o600) os.chmod(companion, 0o600)
def close(self): def close(self):
# Cancelling an asyncio.to_thread caller does not stop its SQLite worker.
# ChatStore/FeatureStore share this connection and lock; wait for any
# in-flight transaction before closing the native connection.
with self.lock:
self.db.close() self.db.close()
def users(self): def users(self):
+7 -4
View File
@@ -20,10 +20,13 @@ git show 7bd893ae:Sources/Chat/ChatViews.swift
git log --all --graph --oneline git log --all --graph --oneline
``` ```
The source repository will be archived using authenticated `tea api` **only after** Published merge commit `d735ee72fabe87acdacc70312547dedd39102b3b` was verified
the import is pushed and remote ancestry/tree checks pass. Archival is reversible; against `origin/master`, including original-tip ancestry and native tree entries.
no source repository deletion or local checkout deletion is part of this operation. After that verification, authenticated `tea api` archived `snowspeeder/hope-voice-ios`
The old local checkout is no longer the development source of truth. on 2026-10-04; a separate GET confirmed `archived: true`. Its description points to
this monorepo. Archival is reversible; no source repository deletion or local
checkout deletion occurred. The old local checkout is no longer the development
source of truth.
## Working here ## Working here
+45
View File
@@ -0,0 +1,45 @@
# Web UX refinement — 2026-10-04
Scope: web only. Native iOS remains unchanged until later parity work.
## Implemented
- Conversation history has per-row, accessible, viewport-clamped native popover actions for pin/unpin, rename and archive/unarchive without opening the conversation. Revision/account/selection guards preserve active runs and navigation drafts during delayed responses. Deletion remains the opened conversation's top-right menu.
- Library explains reference files versus Saved drafts. Files open on entry, with prominent upload and reuse actions. Choosing files closes Library and exposes composer progress, retry/removal and errors. A saved file is revalidated before queuing; nothing sends until explicit message submission. Long filenames appear once with compact, named actions. Unsupported capabilities are explained rather than simulated.
- Scheduled task and memory forms use reusable styles outside the original Library mount. Labels, controls, hints, actions and checkbox alignment remain stacked and readable. Activity closes mobile navigation before opening and keeps a reachable close header during scroll. History scrolls independently so account/Library/Activity remain reachable.
- Memory & data explains account context versus chat history/files, gives examples, and exposes revision-aware save/clear. Compare-latest preserves local edits and requires explicitly accepting a reviewed current revision before separately saving; no automatic conflict overwrite.
- Security shows the actual connected host, HTTPS transport status, current account and session scope, with a real sign-out action. It explains administrator-managed resets and does not fabricate session/device lists, self-service reset, or two-factor controls.
- Successful model changes requalify file/image support; pending/failed refreshes block images, latest-response generation wins, and Retry checks support again.
## Verification
Full real-asset screenshot review used offline mocked same-origin API fixtures, not NAS/account data. 71 baseline captures from frozen committed assets; final 85 screenshots at desktop 1440×1000, mobile 390×844 and 320×740, including internal dialog/panel scrolling. Covers conversation/sidebar, Files/Saved drafts and editor, tasks list/create/edit, Memory & data, Security, Account and Administration.
Artifacts: `/tmp/hope-web-ux-before/`, `/tmp/hope-web-ux-qualified/`; reusable harness `/tmp/hope_web_screenshots.py`. Final capture receipt reports zero browser errors, unknown requests or external requests at all widths. Screenshots were inspected, including task collision baseline and final narrow forms, Library filename/actions, memory editor/navigation and mobile history/footer.
Final integrated offline browser selection: **43 passed** in 70.91s:
```bash
.venv-tests/bin/python -m pytest --noconftest -p no:cacheprovider \
tests/unit/test_settings_web.py tests/unit/test_chat_features_web.py \
tests/unit/test_sidebar_actions_web.py tests/unit/test_chat_web.py \
tests/unit/test_voice_settings_ui.py -q
```
JavaScript syntax and diff whitespace checks passed. Independent read-only source/contract review initially found asynchronous ownership, hidden feedback, capability refresh and revision recovery issues; these were corrected with regressions and final review approved the scope. Earlier retry test failed because it asserted before the awaited retry finished; corrected to wait for completed state, not to remove behavior coverage.
The initial refinement was qualified offline only. Production publication was subsequently authorized; see the deployment receipt below. No speech/device qualification or iOS parity implementation is claimed. Existing untracked design artifacts were left untouched.
## Production release follow-up
Published web commit `41ca590`, then corrected two real CI/shutdown defects discovered by production qualification:
- Run 242 failed before build: a socket-backed runner checkout cannot be bound through the host Docker daemon. Commit `5a02b8f` streams `git archive HEAD` into the throwaway qualification container instead, excluding untracked runtime/secrets and retaining all tests.
- Run 243 revealed SQLite close racing a cancelled `asyncio.to_thread` worker. Commit `ea4e191` serializes connection close with the shared database lock; deterministic regression safely rejects the original implementation and proves the terminal event persists. Packaging assertions follow the safe source-transfer mechanism.
- Run 244's exact Python 3.12 offline qualification passed **148 tests** in 64.69s, with one unrelated Starlette/httpx deprecation warning. Theme artifacts verified. The [production run 244](https://git.srmr.xyz/snowspeeder/hope-voice-api/actions/runs/244) completed **successfully** at 2026-10-04 12:36 UTC, rebuilding speech/web, control and Hermes WebUI images and deploying through the protected rollback-preserving script.
Production checks at `https://192.168.86.2:9178` verified all seven entrypoint assets (`index.html`, `style.css`, `app.js`, `chat.js`, `features.js`, `settings.js`, `hope-themes.css`) HTTP200 and byte-for-byte equal to release `ea4e191`. Anonymous `/api/auth/me` and POST `/token` both returned401. Fresh mobile Chromium at390px rendered the live login gate without browser errors or horizontal overflow. Qualification used an exception for the installation's existing self-signed certificate only; no production TLS policy changed.
Actual Docker `voice` and `hope-control` were running with new images `9475f397d2e2…` and `db756e976829…`, started at12:36:15/12:36:10 UTC respectively. Voice health was `healthy`; control `/health` returned `{"status":"ok"}`. Protected account homes/runtime/certificates and timestamped rollback containers retained. No account credentials, chats, uploads or memory were read or changed for these live checks. No authenticated CRUD/voice/device success is inferred from static assets and health checks.
Neither failed qualification run reached deployment; the original production containers stayed healthy.
+246 -57
View File
@@ -30,24 +30,94 @@ final class ChatFeatureStore: ObservableObject {
private let api: ChatTransport private let api: ChatTransport
private var epoch = 0 private var epoch = 0
private var closed = false private var closed = false
private var capabilityGeneration = 0
private var loadGeneration = 0
init(api: ChatTransport) { self.api = api } init(api: ChatTransport) { self.api = api }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T {
guard !closed else { throw CancellationError() }
let current = epoch let current = epoch
let data = try await api.request(path, method: method, body: body, query: query) let data = try await api.request(path, method: method, body: body, query: query)
guard !closed, current == epoch else { throw CancellationError() } guard !closed, current == epoch else { throw CancellationError() }
try Task.checkCancellation() try Task.checkCancellation()
return try ChatCoding.decoder.decode(T.self, from: data) return try ChatCoding.decoder.decode(T.self, from: data)
} }
func load() async { @discardableResult
func refreshCapabilities() async -> Bool {
guard !closed else { return false }
capabilityGeneration += 1
let generation = capabilityGeneration
enabled = [] // Never retain image qualification while a refresh is pending or failed.
do { do {
let caps: FeatureCapabilities = try await call("api/capabilities") let caps: FeatureCapabilities = try await call("api/capabilities")
guard generation == capabilityGeneration else { return false }
enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key)) enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key))
if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] }; let page: Page = try await call("api/attachments"); attachments = page.attachments } return true
if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts } } catch {
if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks } guard generation == capabilityGeneration else { return false }
if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections } report(error); return false
}
}
func load() async {
loadGeneration += 1
let generation = loadGeneration
guard await refreshCapabilities() else { return }
let capabilities = capabilityGeneration
do {
if enabled.contains("attachments") {
struct Page: Decodable { let attachments: [ChatAttachment] }
let page: Page = try await call("api/attachments")
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
attachments = page.attachments
} else { attachments = [] }
if enabled.contains("artifacts") {
struct Page: Decodable { let artifacts: [ChatArtifact] }
let page: Page = try await call("api/artifacts")
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
artifacts = page.artifacts
} else { artifacts = [] }
if enabled.contains("scheduled_tasks") {
struct Page: Decodable { let tasks: [ScheduledChatTask] }
let page: Page = try await call("api/tasks")
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
tasks = page.tasks
} else { tasks = [] }
if enabled.contains("memory_controls") {
struct Page: Decodable { let sections: [ChatMemory] }
let page: Page = try await call("api/memory")
guard generation == loadGeneration, capabilities == capabilityGeneration else { return }
memory = page.sections
} else { memory = [] }
error = nil error = nil
} catch { report(error) } } catch { if generation == loadGeneration { report(error) } }
}
static func blockedReason(_ item: ChatAttachment, imageInput: Bool) -> String? {
if ["failed", "encrypted", "unavailable"].contains(item.extractionStatus) {
return "This file cannot be used: \(item.extractionStatus). Preview it or upload a readable copy."
}
guard ["ready", "truncated", "image"].contains(item.extractionStatus) else {
return "This file is not ready to use. Refresh and try again."
}
if item.mime.hasPrefix("image/") && !imageInput { return "Image input is not available for the current model. Choose a text file or PDF." }
return nil
}
func validatedAttachment(_ item: ChatAttachment) async -> ChatAttachment? {
guard await refreshCapabilities(), enabled.contains("attachments") else {
if !closed { error = "Private files are unavailable. Refresh and try again." }; return nil
}
let generation = capabilityGeneration
do {
struct Envelope: Decodable { let attachment: ChatAttachment }
let result: Envelope = try await call("api/attachments/\(item.id)")
guard generation == capabilityGeneration else { return nil }
if let reason = Self.blockedReason(result.attachment, imageInput: enabled.contains("image_input")) { error = reason; return nil }
if let i = attachments.firstIndex(where: { $0.id == item.id }) { attachments[i] = result.attachment }
error = nil; return result.attachment
} catch {
guard !closed, generation == capabilityGeneration else { return nil }
if case ChatFailure.status(404) = error { self.error = "This file is no longer available. Refresh your files." }
else { report(error) }
return nil
}
} }
private func report(_ value: Error) { private func report(_ value: Error) {
guard !(value is CancellationError), !closed else { return } guard !(value is CancellationError), !closed else { return }
@@ -55,7 +125,9 @@ final class ChatFeatureStore: ObservableObject {
error = value.localizedDescription error = value.localizedDescription
} }
func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? { func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? {
guard !busy, enabled.contains("attachments") else { return nil } guard !busy, !closed else { return nil }
guard await refreshCapabilities(), enabled.contains("attachments"), !busy else { return nil }
if mime.hasPrefix("image/"), !enabled.contains("image_input") { error = "Image input is unavailable for the current model. Choose a text file or PDF."; return nil }
guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil } guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil }
busy = true; defer { busy = false } busy = true; defer { busy = false }
do { do {
@@ -68,6 +140,7 @@ final class ChatFeatureStore: ObservableObject {
} catch { report(error); return nil } } catch { report(error); return nil }
} }
func preview(_ attachment: ChatAttachment) async { func preview(_ attachment: ChatAttachment) async {
guard !closed else { return }
previewAttachment = attachment previewAttachment = attachment
previewLoading = true previewLoading = true
previewError = nil previewError = nil
@@ -121,10 +194,13 @@ final class ChatFeatureStore: ObservableObject {
do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil } do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil }
} }
func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } } func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } }
func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String) async { func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String, repeatCount: Int? = nil, paused: Bool = false) async {
do { do {
struct Result: Decodable { let task: ScheduledChatTask } struct Result: Decodable { let task: ScheduledChatTask }
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: ["name": name, "schedule": schedule, "prompt": prompt]) var body: [String: Any] = ["name": name, "schedule": schedule, "prompt": prompt]
if let repeatCount { guard repeatCount > 0 else { error = "Repeat count must be a positive whole number."; return }; body["repeat"] = repeatCount }
if item == nil { body["paused"] = paused }
let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: body)
if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil
} catch { report(error) } } catch { report(error) }
} }
@@ -132,15 +208,37 @@ final class ChatFeatureStore: ObservableObject {
do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) } do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) }
} }
func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } } func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } }
func saveMemory(_ item: ChatMemory, content: String) async { func latestMemory(_ item: ChatMemory) async -> ChatMemory? {
do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]); if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }; error = nil } catch { report(error) } do {
struct Page: Decodable { let sections: [ChatMemory] }
let page: Page = try await call("api/memory")
guard let latest = page.sections.first(where: { $0.section == item.section }) else { throw ChatFailure.invalidResponse }
memory = page.sections; error = nil; return latest
} catch { report(error); return nil }
} }
func close() { closed = true; epoch += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil } @discardableResult
func saveMemory(_ item: ChatMemory, content: String) async -> Bool {
do {
let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content])
if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result }
error = nil; return true
} catch {
if case ChatFailure.status(409) = error { self.error = "Memory changed on the server. Compare the latest version, then explicitly accept its revision before saving your draft." }
else { report(error) }
return false
} }
}
func close() { closed = true; epoch += 1; capabilityGeneration += 1; loadGeneration += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil }
}
enum ChatLibraryDestination { case files, tasks, memory }
struct ChatLibraryView: View { struct ChatLibraryView: View {
@ObservedObject var store: ChatFeatureStore @ObservedObject var store: ChatFeatureStore
@Binding var selectedAttachmentIds: Set<String> var initialDestination: ChatLibraryDestination = .files
var selectedAttachmentIds: Set<String> = []
var onUseAttachment: ((ChatAttachment) async -> Bool)?
var onRemoveAttachment: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var importing = false @State private var importing = false
@State private var photo: PhotosPickerItem? @State private var photo: PhotosPickerItem?
@@ -156,6 +254,15 @@ struct ChatLibraryView: View {
@State private var name = "" @State private var name = ""
@State private var schedule = "" @State private var schedule = ""
@State private var prompt = "" @State private var prompt = ""
@State private var repeatCount = ""
@State private var createPaused = true
@State private var usingAttachment = false
@State private var feedback: String?
@State private var latestMemory: ChatMemory?
@State private var acceptedMemory: ChatMemory?
@State private var memoryWorking = false
@State private var memoryRequiresReview = false
@State private var confirmingClear = false
@State private var memory: ChatMemory? @State private var memory: ChatMemory?
@State private var memoryContent = "" @State private var memoryContent = ""
@State private var deleting: (() async -> Void)? @State private var deleting: (() async -> Void)?
@@ -165,57 +272,75 @@ struct ChatLibraryView: View {
if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } } if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } }
if loadingCapabilities { if loadingCapabilities {
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
} else if store.enabled.isEmpty { } else if (initialDestination == .files && !store.enabled.contains("attachments") && !store.enabled.contains("artifacts")) || (initialDestination == .tasks && !store.enabled.contains("scheduled_tasks")) || (initialDestination == .memory && !store.enabled.contains("memory_controls")) {
ContentUnavailableView("Library features aren’t available", systemImage: "lock", description: Text("Your server has not enabled private files, documents, schedules, or memory controls for this account.")) ContentUnavailableView("Not available on this server", systemImage: "lock", description: Text("These features must be enabled for your account by the Hope server."))
} }
if store.enabled.contains("attachments") { if initialDestination == .files && store.enabled.contains("attachments") {
Section("Files") { Section("Private files") {
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy) Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary)
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) } if let feedback { Text(feedback).font(.footnote) }
if store.busy { ProgressView("Uploading private file…") }
Button("Upload file", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Upload photo", systemImage: "photo") }.disabled(store.busy) }
ForEach(store.attachments) { item in ForEach(store.attachments) { item in
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Button { Text(item.filename).font(.body)
if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) } Spacer()
else if selectedAttachmentIds.count < 8 { selectedAttachmentIds.insert(item.id) }
else { store.error = "Select up to 8 files for one message." }
} label: { Label(item.filename, systemImage: selectedAttachmentIds.contains(item.id) ? "checkmark.circle.fill" : "circle").frame(minHeight: 44) }
.disabled(item.extractionStatus == "failed" || item.extractionStatus == "encrypted" || (item.mime.hasPrefix("image/") && !store.enabled.contains("image_input")))
Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44) Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44)
} }
Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary) Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary)
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); selectedAttachmentIds.remove(item.id) } } } if let reason = ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) {
Text(reason).font(.footnote).foregroundStyle(.secondary)
}
if selectedAttachmentIds.contains(item.id) {
Label("Queued for your next message", systemImage: "checkmark.circle.fill").font(.footnote)
Button("Remove from message") { onRemoveAttachment?(item.id) }
} else {
Button("Use in conversation", systemImage: "arrow.up.message") {
Task {
usingAttachment = true; defer { usingAttachment = false }
guard let validated = await store.validatedAttachment(item) else { return }
guard let onUseAttachment else { store.error = "Open Library from a conversation to use this file."; return }
if await onUseAttachment(validated) { dismiss() }
}
}.disabled(usingAttachment || store.busy || onUseAttachment == nil || ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) != nil)
.accessibilityHint("Queues this private file in your draft. Does not send a message.")
}
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); if store.error == nil { onRemoveAttachment?(item.id) } } } }
} }
Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary) Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary)
} }
} }
if store.enabled.contains("artifacts") { if initialDestination == .files && store.enabled.contains("artifacts") {
Section("Artifacts") { Section("Saved drafts") {
Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true } Text("Documents you save here are separate from conversation history.").font(.footnote).foregroundStyle(.secondary)
Button("New saved draft", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } } ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } }
} }
} }
if store.enabled.contains("scheduled_tasks") { if initialDestination == .tasks && store.enabled.contains("scheduled_tasks") {
Section("Scheduled tasks") { Section("Scheduled tasks") {
Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary) Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary)
Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; taskEditor = true } Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; repeatCount = ""; createPaused = true; taskEditor = true }
ForEach(store.tasks) { item in ForEach(store.tasks) { item in
VStack(alignment: .leading) { VStack(alignment: .leading) {
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44) Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; repeatCount = item.repeat.map(String.init) ?? ""; taskEditor = true }.frame(minHeight: 44)
Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary) Text("\(item.enabled == true ? "Active" : item.enabled == false ? "Paused" : "Status not provided") · \(item.schedule ?? "Schedule not provided")").font(.caption).foregroundStyle(.secondary)
HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } } HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } }
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
} }
} }
} }
if store.enabled.contains("memory_controls") { if initialDestination == .memory && store.enabled.contains("memory_controls") {
Section("Account memory") { Section("Durable account memory") {
ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } } Text("Guidance shapes future replies. About you stores lasting preferences and personal context. Neither is your conversation history.").font(.footnote).foregroundStyle(.secondary)
ForEach(store.memory) { item in Button(item.section == "user" ? "About you" : "Guidance") { memory = item; memoryContent = item.content; latestMemory = nil; acceptedMemory = nil; memoryRequiresReview = false } }
Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary) Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary)
} }
} }
} }
.navigationTitle("Library and tools") .navigationTitle(initialDestination == .files ? "Library" : initialDestination == .tasks ? "Scheduled tasks" : "Memory")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task { await store.load(); loadingCapabilities = false } .task { await store.load(); loadingCapabilities = false }
.fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in .fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in
@@ -226,11 +351,11 @@ struct ChatLibraryView: View {
guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return } guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return }
let mime: String let mime: String
switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" } switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" }
_ = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) if let uploaded = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) { feedback = "\(uploaded.filename) uploaded privately. Tap Use in conversation to queue it." }
} catch { store.error = "Could not read this file. Select it again to retry." } } catch { store.error = "Could not read this file. Select it again to retry." }
} } } }
} }
.onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { _ = await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } } .onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { if await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") != nil { feedback = "Photo uploaded privately. Tap Use in conversation to queue it." } } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } }
.sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) { .sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
@@ -248,20 +373,7 @@ struct ChatLibraryView: View {
} }
.sheet(isPresented: $artifactEditor) { artifactForm } .sheet(isPresented: $artifactEditor) { artifactForm }
.sheet(isPresented: $taskEditor) { taskForm } .sheet(isPresented: $taskEditor) { taskForm }
.sheet(item: $memory) { item in .sheet(item: $memory) { item in memoryForm(item) }
NavigationStack {
Form {
Section("Memory") { TextEditor(text: $memoryContent).frame(minHeight: 260).accessibilityLabel("Memory content") }
Text("An empty value clears this section. Existing sessions may cache earlier memory.").font(.footnote)
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(item.section.capitalized)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveMemory(item, content: memoryContent); if store.error == nil { memory = nil } } } }
}
}
}
.confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } } .confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } }
} }
} }
@@ -278,17 +390,94 @@ struct ChatLibraryView: View {
Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote) Text("Historical content uses the current title and kind. Saving creates a new revision.").font(.footnote)
} }
if let error = store.error { Text(error).foregroundStyle(.red) } if let error = store.error { Text(error).foregroundStyle(.red) }
}.navigationTitle(artifact == nil ? "New artifact" : "Edit artifact") }.navigationTitle(artifact == nil ? "New saved draft" : "Edit saved draft")
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { artifactEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveArtifact(artifact, title: title, kind: kind, content: content); if store.error == nil { artifactEditor = false } } }.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }
} }
} }
private func memoryForm(_ item: ChatMemory) -> some View {
NavigationStack {
Form {
Section("Your local draft") {
TextEditor(text: $memoryContent).frame(minHeight: 220).accessibilityLabel("Memory draft").disabled(memoryWorking)
Text("Your draft stays here while you compare server changes. Saving affects future runs; existing sessions may retain earlier memory.").font(.footnote).foregroundStyle(.secondary)
}
Section("Revision review") {
Button("Compare latest server version") {
Task {
memoryWorking = true; defer { memoryWorking = false }
if let value = await store.latestMemory(item) { latestMemory = value; acceptedMemory = nil; memoryRequiresReview = true }
}
}.disabled(memoryWorking)
if let latestMemory {
Text(latestMemory.revision == item.revision ? "The server revision matches the version you opened." : "The server has a newer revision. Review it before overwriting with your draft.").font(.footnote)
Text(latestMemory.content.isEmpty ? "This section is empty on the server." : latestMemory.content).textSelection(.enabled)
Button("Accept this revision for my next save") { acceptedMemory = latestMemory; memoryRequiresReview = false }
.disabled(acceptedMemory?.revision == latestMemory.revision)
if acceptedMemory != nil { Text("Revision accepted. Saving will write your local draft, not the server text.").font(.footnote) }
}
}
Section {
Button("Clear this memory section", role: .destructive) { confirmingClear = true }.disabled(memoryWorking || memoryRequiresReview)
Text("Clearing removes only this durable section, not chats or all account data.").font(.footnote).foregroundStyle(.secondary)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(item.section == "user" ? "About you" : "Guidance")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } }
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
if memoryContent.isEmpty { confirmingClear = true }
else { Task { await writeMemory(acceptedMemory ?? item, content: memoryContent) } }
}.disabled(memoryWorking || memoryRequiresReview)
}
}
.confirmationDialog("Clear this memory section?", isPresented: $confirmingClear, titleVisibility: .visible) {
Button("Clear section", role: .destructive) { Task { await writeMemory(acceptedMemory ?? item, content: "") } }
} message: { Text("This removes durable memory only. Conversation history is unchanged.") }
}
}
private func writeMemory(_ base: ChatMemory, content: String) async {
memoryWorking = true; defer { memoryWorking = false }
if await store.saveMemory(base, content: content) { memory = nil }
else if store.error?.contains("Memory changed") == true { memoryRequiresReview = true; acceptedMemory = nil }
}
private var taskForm: some View { private var taskForm: some View {
NavigationStack { NavigationStack {
Form { TextField("Name", text: $name); TextField("Hermes schedule", text: $schedule); Text("Use a schedule accepted by your Hermes server. Hope does not invent a separate scheduling format.").font(.footnote); TextEditor(text: $prompt).frame(minHeight: 180); if let error = store.error { Text(error).foregroundStyle(.red) } } Form {
.navigationTitle(task == nil ? "New task" : "Edit task") Section("Task") {
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { await store.saveTask(task, name: name, schedule: schedule, prompt: prompt); if store.error == nil { taskEditor = false } } }.disabled(name.isEmpty || schedule.isEmpty || prompt.isEmpty) } } TextField("Task name", text: $name)
TextField("Schedule", text: $schedule).autocorrectionDisabled().textInputAutocapitalization(.never)
Text("Enter a schedule accepted by your server, for example cron: 0 9 * * *. The server validates it; timezone and next run are not inferred here.").font(.footnote).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
Text("Instructions").font(.subheadline.weight(.semibold))
TextEditor(text: $prompt).frame(minHeight: 150).accessibilityLabel("Task instructions")
Text("Tell Hope what to do when this task runs. For example: review my plans and suggest one practical next step.").font(.footnote).foregroundStyle(.secondary)
} }
} }
Section("Options") {
TextField("Repeat count (optional)", text: $repeatCount).keyboardType(.numberPad)
Text("Leave blank for the server default, or enter a positive number of executions.").font(.footnote).foregroundStyle(.secondary)
if task == nil { Toggle("Create paused", isOn: $createPaused) }
Text("Tasks execute on your Hope server. This app does not deliver push notifications or show task output history.").font(.footnote).foregroundStyle(.secondary)
}
if let error = store.error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(task == nil ? "New scheduled task" : "Edit task")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } }
ToolbarItem(placement: .confirmationAction) {
Button("Save") { Task {
let trimmed = repeatCount.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && (Int(trimmed) == nil || (Int(trimmed) ?? 0) <= 0) { store.error = "Repeat count must be a positive whole number."; return }
await store.saveTask(task, name: name, schedule: schedule, prompt: prompt, repeatCount: Int(trimmed), paused: createPaused)
if store.error == nil { taskEditor = false }
} }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || schedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
} }
struct PrivatePDFPreview: UIViewRepresentable { struct PrivatePDFPreview: UIViewRepresentable {
+19 -3
View File
@@ -20,7 +20,22 @@ struct HopeMessage: Codable, Identifiable, Equatable {
var status: String var status: String
var runId: String? var runId: String?
var createdAt: Double var createdAt: Double
// Attachments are deliberately unavailable until their contract is qualified. var attachments: [String] = []
enum CodingKeys: String, CodingKey { case id, conversationId, role, content, status, runId, createdAt, attachments }
init(id: String, conversationId: String, role: String, content: String, status: String, runId: String?, createdAt: Double, attachments: [String] = []) {
self.id = id; self.conversationId = conversationId; self.role = role; self.content = content; self.status = status; self.runId = runId; self.createdAt = createdAt; self.attachments = attachments
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationId = try values.decode(String.self, forKey: .conversationId)
role = try values.decode(String.self, forKey: .role)
content = try values.decode(String.self, forKey: .content)
status = try values.decode(String.self, forKey: .status)
runId = try values.decodeIfPresent(String.self, forKey: .runId)
createdAt = try values.decode(Double.self, forKey: .createdAt)
attachments = try values.decodeIfPresent([String].self, forKey: .attachments) ?? []
}
} }
struct HopeRun: Codable, Identifiable, Equatable { struct HopeRun: Codable, Identifiable, Equatable {
var id: String var id: String
@@ -188,7 +203,7 @@ final class OfflineChatAPI: ChatTransport {
if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) } if method == "GET" { return try JSONSerialization.data(withJSONObject: [kind: featureObjects[kind] ?? [], "next_cursor": NSNull()]) }
var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now
if kind == "artifacts" { item["revision"] = 1 } if kind == "artifacts" { item["revision"] = 1 }
if kind == "tasks" { item["enabled"] = true } if kind == "tasks" { item["enabled"] = !(body["paused"] as? Bool ?? false) }
featureObjects[kind, default: []].insert(item, at: 0) featureObjects[kind, default: []].insert(item, at: 0)
return try JSONSerialization.data(withJSONObject: [singular: item]) return try JSONSerialization.data(withJSONObject: [singular: item])
} }
@@ -223,7 +238,8 @@ final class OfflineChatAPI: ChatTransport {
if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) } if method == "POST" { let c = HopeConversation(id: UUID().uuidString, title: body["title"] as? String ?? "New chat", workspaceId: body["workspace_id"] as? String, pinned: false, pinRank: 0, archived: false, temporary: false, revision: 1, createdAt: now, updatedAt: now); conversations.insert(c, at: 0); return try wrap("conversation", c) }
let q = query.first(where: { $0.name == "q" })?.value ?? "" let q = query.first(where: { $0.name == "q" })?.value ?? ""
let archived = query.first(where: { $0.name == "archived" })?.value == "true" let archived = query.first(where: { $0.name == "archived" })?.value == "true"
let filtered = conversations.filter { $0.archived == archived && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) } let workspace = query.first(where: { $0.name == "workspace_id" })?.value
let filtered = conversations.filter { $0.archived == archived && (workspace == nil || $0.workspaceId == workspace) && (q.isEmpty || $0.title.localizedCaseInsensitiveContains(q) || (messages[$0.id] ?? []).contains { $0.content.localizedCaseInsensitiveContains(q) }) }
return try JSONSerialization.data(withJSONObject: ["conversations": JSONSerialization.jsonObject(with: encode(filtered)), "next_cursor": NSNull()]) return try JSONSerialization.data(withJSONObject: ["conversations": JSONSerialization.jsonObject(with: encode(filtered)), "next_cursor": NSNull()])
} }
if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) { if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) {
+135 -26
View File
@@ -17,6 +17,9 @@ final class ChatStore: ObservableObject {
@Published var olderMessagesCursor: String? @Published var olderMessagesCursor: String?
private var currentQuery = "" private var currentQuery = ""
private var currentArchived = false private var currentArchived = false
private var currentWorkspace: String?
@Published private(set) var mutatingConversationIds: Set<String> = []
private(set) var draftGeneration = 0
private var searchGeneration = 0 private var searchGeneration = 0
@Published var approval: (id: String, prompt: String)? @Published var approval: (id: String, prompt: String)?
@Published var researchAvailable = false @Published var researchAvailable = false
@@ -31,6 +34,22 @@ final class ChatStore: ObservableObject {
@Published var modelNote: String? @Published var modelNote: String?
let features: ChatFeatureStore let features: ChatFeatureStore
@Published var selectedAttachmentIds: Set<String> = [] @Published var selectedAttachmentIds: Set<String> = []
private var queuedAttachments: [String: ChatAttachment] = [:]
var canSendAttachments: Bool {
guard !closed else { return false }
if selectedAttachmentIds.isEmpty { return true }
guard features.enabled.contains("attachments"), selectedAttachmentIds.count <= 8 else { return false }
return selectedAttachmentIds.allSatisfy { id in
guard let attachment = queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id }) else { return false }
return attachmentEligible(attachment)
}
}
private func attachmentEligible(_ attachment: ChatAttachment) -> Bool {
let image = ["image/png", "image/jpeg", "image/webp"].contains(attachment.mime)
let document = ["text/plain", "text/markdown", "application/pdf"].contains(attachment.mime)
return (image && attachment.extractionStatus == "image" && features.enabled.contains("image_input")) ||
(document && ["ready", "truncated"].contains(attachment.extractionStatus))
}
private let api: ChatTransport private let api: ChatTransport
private var generation = 0 private var generation = 0
private var stream: Task<Void, Never>? private var stream: Task<Void, Never>?
@@ -43,11 +62,11 @@ final class ChatStore: ObservableObject {
init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) } init(api: ChatTransport) { self.api = api; self.features = ChatFeatureStore(api: api) }
deinit { stream?.cancel(); syncTask?.cancel() } deinit { stream?.cancel(); syncTask?.cancel() }
private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { private func call<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = [], selectionBound: Bool = true) async throws -> T {
let epoch = generation let epoch = generation
let data = try await api.request(path, method: method, body: body, query: query) let data = try await api.request(path, method: method, body: body, query: query)
try Task.checkCancellation() try Task.checkCancellation()
guard !closed, generation == epoch else { throw CancellationError() } guard !closed, !selectionBound || generation == epoch else { throw CancellationError() }
return try ChatCoding.decoder.decode(T.self, from: data) return try ChatCoding.decoder.decode(T.self, from: data)
} }
private func failure(_ value: Error) async { private func failure(_ value: Error) async {
@@ -55,27 +74,33 @@ final class ChatStore: ObservableObject {
if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return } if case ChatFailure.status(let code) = value, code == 401 || code == 403 { await onUnauthorized?(); return }
error = value.localizedDescription error = value.localizedDescription
} }
func load(query: String = "", archived: Bool = false, more: Bool = false) async { func load(query: String = "", archived: Bool = false, workspace: String? = nil, more: Bool = false) async {
guard !closed else { return } guard !closed else { return }
currentQuery = query; currentArchived = archived let sameScope = currentQuery == query && currentArchived == archived && currentWorkspace == workspace
currentQuery = query; currentArchived = archived; currentWorkspace = workspace
searchGeneration += 1 searchGeneration += 1
let search = searchGeneration let search = searchGeneration
searching = true searching = true
defer { if search == searchGeneration { searching = false } } defer { if search == searchGeneration { searching = false } }
do { do {
var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")] var q = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "archived", value: String(archived)), URLQueryItem(name: "limit", value: "50")]
if more, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) } if let workspace { q.append(URLQueryItem(name: "workspace_id", value: workspace)) }
let page: ConversationPage = try await call("api/conversations", query: q) if more, sameScope, let nextCursor { q.append(URLQueryItem(name: "cursor", value: nextCursor)) }
let page: ConversationPage = try await call("api/conversations", query: q, selectionBound: false)
guard search == searchGeneration else { return } guard search == searchGeneration else { return }
if more { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } } if more && sameScope { let existing = Set(conversations.map(\.id)); conversations += page.conversations.filter { !existing.contains($0.id) } }
else { conversations = page.conversations } else { conversations = page.conversations }
nextCursor = page.nextCursor nextCursor = page.nextCursor
let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces let spaces: WorkspacePage = try await call("api/workspaces", selectionBound: false)
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences guard search == searchGeneration else { return }; workspaces = spaces.workspaces
let prefs: PreferencesEnvelope = try await call("api/preferences", selectionBound: false)
guard search == searchGeneration else { return }; preferences = prefs.preferences
error = nil error = nil
} catch { await failure(error) } } catch { await failure(error) }
} }
func open(_ conversation: HopeConversation) async { func open(_ conversation: HopeConversation) async {
guard !closed else { return }
draftGeneration += 1
if let selected { drafts[selected.id] = draft } if let selected { drafts[selected.id] = draft }
generation += 1; stream?.cancel(); stream = nil generation += 1; stream?.cancel(); stream = nil
selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil
@@ -100,18 +125,26 @@ final class ChatStore: ObservableObject {
do { do {
var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace } var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace }
let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body) let response: ConversationEnvelope = try await call("api/conversations", method: "POST", body: body)
conversations.insert(response.conversation, at: 0); await open(response.conversation) let selectionEpoch = generation
await refreshList()
guard !closed, generation == selectionEpoch else { busy = false; return }
await open(response.conversation)
} catch { await failure(error) } } catch { await failure(error) }
} }
func send() async { func send() async {
guard let selected, !busy, activeRun == nil else { return } guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return }
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines) let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
guard canSendAttachments else {
error = "Some attachments are unavailable for the current model. Refresh Files, choose a supported model, or remove those files before sending."
return
}
busy = true; error = nil; defer { busy = false } busy = true; error = nil; defer { busy = false }
let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable) let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable)
if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) } if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) }
do { do {
let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"]) let response: SendEnvelope = try await call("api/conversations/\(selected.id)/messages", method: "POST", body: ["content": content, "idempotency_key": pendingKey!.key, "attachment_ids": Array(selectedAttachmentIds).sorted(), "mode": research && researchAvailable ? "research" : "normal"])
draftGeneration += 1
upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = [] upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = []
if response.run.active { replay(response.run.id) } if response.run.active { replay(response.run.id) }
else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages } else { let detail: ConversationDetail = try await call("api/conversations/\(selected.id)"); messages = detail.messages }
@@ -173,24 +206,78 @@ final class ChatStore: ObservableObject {
do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) } do { let result: RunEnvelope = try await call("api/runs/\(activeRun.id)/approval", method: "POST", body: ["approval_id": approval.id, "decision": decision]); upsert(result.run); self.approval = nil; replay(result.run.id) }
catch { await failure(error) } catch { await failure(error) }
} }
func canMutate(_ conversation: HopeConversation) -> Bool {
!closed && !mutatingConversationIds.contains(conversation.id) &&
(selected?.id != conversation.id || (!busy && activeRun == nil))
}
private func refreshList() async {
await load(query: currentQuery, archived: currentArchived, workspace: currentWorkspace)
}
private func updateMetadata(_ value: HopeConversation) {
if selected?.id == value.id { selected = value }
if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) {
conversations.removeAll { $0.id == value.id }
} else if let index = conversations.firstIndex(where: { $0.id == value.id }) {
conversations[index] = value
}
}
func patch(_ conversation: HopeConversation, changes: [String: Any]) async { func patch(_ conversation: HopeConversation, changes: [String: Any]) async {
guard canMutate(conversation) else { return }
mutatingConversationIds.insert(conversation.id)
defer { mutatingConversationIds.remove(conversation.id) }
var body = changes; body["revision"] = conversation.revision var body = changes; body["revision"] = conversation.revision
do { do {
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body) let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body, selectionBound: false)
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation } updateMetadata(response.conversation)
if selected?.id == conversation.id { selected = response.conversation } await refreshList()
} catch ChatFailure.status(409) {
do {
let detail: ConversationDetail = try await call("api/conversations/\(conversation.id)", selectionBound: false)
updateMetadata(detail.conversation)
await refreshList()
guard !closed else { return }
error = "This chat changed elsewhere. Its latest details are loaded. Review your change and try again."
} catch { await failure(error) }
} catch { await failure(error) } } catch { await failure(error) }
} }
func delete(_ conversation: HopeConversation) async { func delete(_ conversation: HopeConversation) async {
guard canMutate(conversation) else { return }
mutatingConversationIds.insert(conversation.id)
defer { mutatingConversationIds.remove(conversation.id) }
do { do {
struct Result: Decodable { let ok: Bool } struct Result: Decodable { let ok: Bool }
let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE") let _: Result = try await call("api/conversations/\(conversation.id)", method: "DELETE", selectionBound: false)
conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil conversations.removeAll { $0.id == conversation.id }; drafts[conversation.id] = nil
if selected?.id == conversation.id { generation += 1; stream?.cancel(); selected = nil; messages = []; runs = []; draft = "" } if selected?.id == conversation.id { clearSelection() }
await refreshList()
} catch { await failure(error) } } catch { await failure(error) }
} }
private func clearSelection() {
generation += 1; draftGeneration += 1; stream?.cancel(); stream = nil
selected = nil; messages = []; runs = []; draft = ""; selectedAttachmentIds = []
pendingKey = nil; approval = nil; progress = nil; exportText = nil; exportFile = nil; olderMessagesCursor = nil
}
func queuedAttachment(_ id: String) -> ChatAttachment? {
queuedAttachments[id] ?? features.attachments.first(where: { $0.id == id })
}
/// Bind asynchronous library/upload results to the composer that opened them.
@discardableResult
func queueAttachment(_ attachment: ChatAttachment, expectedDraftGeneration: Int? = nil) async -> Bool {
guard !closed, let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id),
expectedDraftGeneration == nil || expectedDraftGeneration == draftGeneration else { return false }
guard features.enabled.contains("attachments") else { error = "Attachments are unavailable. Refresh Files and try again."; return false }
guard attachmentEligible(attachment) else {
error = "This file cannot be used with the current model. Choose an extracted document or a supported image."; return false
}
guard selectedAttachmentIds.contains(attachment.id) || selectedAttachmentIds.count < 8 else {
error = "Use at most eight attachments per message."; return false
}
queuedAttachments[attachment.id] = attachment
selectedAttachmentIds.insert(attachment.id); pendingKey = nil; error = nil
return true
}
func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async { func branch(_ message: HopeMessage, edited: String? = nil, regenerate: Bool = false) async {
guard let selected, !busy, activeRun == nil else { return } guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return }
busy = true busy = true
do { do {
let target: HopeMessage let target: HopeMessage
@@ -202,10 +289,26 @@ final class ChatStore: ObservableObject {
if let edited { body["content"] = edited } if let edited { body["content"] = edited }
else if regenerate { body["content"] = target.content } else if regenerate { body["content"] = target.content }
let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body) let response: ConversationEnvelope = try await call("api/conversations/\(selected.id)/branch", method: "POST", body: body)
conversations.insert(response.conversation, at: 0); await open(response.conversation) let selectionEpoch = generation
await refreshList()
guard !closed, generation == selectionEpoch else { busy = false; return }
await open(response.conversation)
guard !closed, self.selected?.id == response.conversation.id else { busy = false; return }
if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments) }
else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments) }
if edited != nil || regenerate, !target.attachments.isEmpty {
let attachmentEpoch = generation
_ = await features.refreshCapabilities()
guard !closed, generation == attachmentEpoch, self.selected?.id == response.conversation.id else { busy = false; return }
for id in target.attachments {
struct AttachmentEnvelope: Decodable { let attachment: ChatAttachment }
let metadata: AttachmentEnvelope = try await call("api/attachments/\(id)")
queuedAttachments[id] = metadata.attachment
}
}
busy = false busy = false
if let edited { draft = edited; await send() } if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments); await send() }
else if regenerate { draft = target.content; await send() } else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments); await send() }
} catch { busy = false; await failure(error) } } catch { busy = false; await failure(error) }
} }
@discardableResult @discardableResult
@@ -233,6 +336,8 @@ final class ChatStore: ObservableObject {
let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE") let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE")
workspaces.removeAll { $0.id == workspace.id } workspaces.removeAll { $0.id == workspace.id }
for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil } for i in conversations.indices where conversations[i].workspaceId == workspace.id { conversations[i].workspaceId = nil }
if selected?.workspaceId == workspace.id { selected?.workspaceId = nil }
await refreshList()
} catch { await failure(error) } } catch { await failure(error) }
} }
func savePreferences(palette: String, appearance: String) async { func savePreferences(palette: String, appearance: String) async {
@@ -246,7 +351,7 @@ final class ChatStore: ObservableObject {
} catch { modelNote = "Server model settings are not available." } } catch { modelNote = "Server model settings are not available." }
} }
func saveModel() async { func saveModel() async {
do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel]); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message } do { let result: ChatSettingsSaved = try await call("api/settings/model", method: "PUT", body: ["model": model, "thinking_level": thinkingLevel], selectionBound: false); model = result.settings.model; thinkingLevel = result.settings.thinkingLevel; modelNote = result.message; _ = await features.refreshCapabilities(); await loadResearch() }
catch { await failure(error) } catch { await failure(error) }
} }
func loadResearch() async { func loadResearch() async {
@@ -278,20 +383,20 @@ final class ChatStore: ObservableObject {
} }
} }
} }
private func synchronize() async { func synchronize() async {
do { do {
struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? } struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? }
struct Result: Decodable { let changes: [Change]; let cursor: Int } struct Result: Decodable { let changes: [Change]; let cursor: Int }
let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))]) let result: Result = try await call("api/sync", query: [URLQueryItem(name: "after", value: String(syncCursor))], selectionBound: false)
syncCursor = result.cursor syncCursor = result.cursor
guard !result.changes.isEmpty else { return } guard !result.changes.isEmpty else { return }
for change in result.changes { for change in result.changes {
if change.kind == "conversation" { if change.kind == "conversation" {
if change.deleted { if change.deleted {
conversations.removeAll { $0.id == change.id } conversations.removeAll { $0.id == change.id }
if selected?.id == change.id { stream?.cancel(); self.selected = nil; messages = []; runs = []; draft = "" } if selected?.id == change.id { clearSelection() }
} else if let value = change.data?.conversation { } else if let value = change.data?.conversation {
if value.archived != currentArchived { conversations.removeAll { $0.id == value.id } } if value.archived != currentArchived || (currentWorkspace != nil && value.workspaceId != currentWorkspace) { conversations.removeAll { $0.id == value.id } }
else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value } else if let i = conversations.firstIndex(where: { $0.id == change.id }) { conversations[i] = value }
else if currentQuery.isEmpty { conversations.insert(value, at: 0) } else if currentQuery.isEmpty { conversations.insert(value, at: 0) }
} }
@@ -302,6 +407,10 @@ final class ChatStore: ObservableObject {
else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } } else if let value = change.data?.workspace { if let i = workspaces.firstIndex(where: { $0.id == value.id }) { workspaces[i] = value } else { workspaces.append(value) } }
} }
} }
// Reconcile only loaded metadata. Re-fetching page one here discards
// older loaded pages and their continuation cursor every sync tick.
// Search membership depends on message content, so newly matching rows
// are discovered only by an explicit search refresh, not guessed here.
if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) { if let selected, result.changes.contains(where: { $0.id == selected.id && !$0.deleted }) {
let detail: ConversationDetail = try await call("api/conversations/\(selected.id)") let detail: ConversationDetail = try await call("api/conversations/\(selected.id)")
self.selected = detail.conversation self.selected = detail.conversation
@@ -311,7 +420,7 @@ final class ChatStore: ObservableObject {
} catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } } } catch { if case ChatFailure.status(let status) = error, status == 401 || status == 403 { await failure(error) } }
} }
/// Invalidate every outstanding callback before clearing account-visible content. /// Invalidate every outstanding callback before clearing account-visible content.
func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; closed = true; generation += 1; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil } func close() { syncTask?.cancel(); syncTask = nil; exportText = nil; exportFile = nil; research = false; features.close(); selectedAttachmentIds = []; queuedAttachments = [:]; closed = true; generation += 1; draftGeneration += 1; searchGeneration += 1; mutatingConversationIds = []; stream?.cancel(); stream = nil; api.cancel(); conversations = []; workspaces = []; selected = nil; messages = []; runs = []; draft = ""; drafts = [:]; cursors = [:]; pendingKey = nil; approval = nil; progress = nil; error = nil }
} }
struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? } struct ChatModelChoice: Decodable, Identifiable { let id: String; let thinkingLevels: [String]? }
struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String } struct ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }
+98 -24
View File
@@ -5,6 +5,12 @@ struct NativeChatView: View {
@ObservedObject var store: ChatStore @ObservedObject var store: ChatStore
let talk: (String) async -> Void let talk: (String) async -> Void
let logout: () async -> Void let logout: () async -> Void
let serverURL: String
let accountName: String
let voiceConversationLocked: (String) -> Bool
@State private var libraryDestination: ChatLibraryDestination = .files
@State private var attachmentGeneration = 0
@State private var showSecurity = false
@State private var query = "" @State private var query = ""
@State private var archived = false @State private var archived = false
@State private var workspace: String? @State private var workspace: String?
@@ -27,13 +33,13 @@ struct NativeChatView: View {
if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } } if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } }
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } } if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
if !recent.isEmpty { Section(archived ? "Archived" : "Recent chats") { rows(recent) } } if !recent.isEmpty { Section(archived ? "Archived" : "Recent chats") { rows(recent) } }
if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, more: true) } } } if store.nextCursor != nil { Button("Load more") { Task { await store.load(query: query, archived: archived, workspace: workspace, more: true) } } }
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal)) .background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
.navigationTitle("Chats") .navigationTitle("Chats")
.searchable(text: $query, prompt: "Search chats and messages") .searchable(text: $query, prompt: "Search chats and messages")
.task(id: query + String(archived)) { .task(id: query + String(archived) + (workspace ?? "")) {
do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {} do { try await Task.sleep(for: .milliseconds(250)); try Task.checkCancellation(); await refresh() } catch {}
} }
.refreshable { await refresh() } .refreshable { await refresh() }
@@ -42,15 +48,22 @@ struct NativeChatView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Menu { Menu {
Menu("Settings", systemImage: "gearshape") {
Button("Appearance and model") { showPreferences = true }
Button("Security", systemImage: "lock.shield") { showSecurity = true }
Button("Manage workspaces") { showWorkspaces = true }
Button("Sign out", role: .destructive) { Task { await logout() } }
}
Menu("Filter chats", systemImage: "line.3.horizontal.decrease") {
Toggle("Show archived", isOn: $archived) Toggle("Show archived", isOn: $archived)
Picker("Workspace", selection: $workspace) { Picker("Workspace", selection: $workspace) {
Text("All chats").tag(nil as String?) Text("All chats").tag(nil as String?)
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) } ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
} }
Button("Library and tools") { showLibrary = true } }
Button("Manage workspaces") { showWorkspaces = true } Button("Library", systemImage: "books.vertical") { openLibrary(.files) }
Button("Appearance and model") { showPreferences = true } Button("Scheduled tasks", systemImage: "calendar") { openLibrary(.tasks) }
Button("Sign out", role: .destructive) { Task { await logout() } } Button("Memory & data", systemImage: "brain") { openLibrary(.memory) }
} label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) } } label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
.accessibilityLabel("Chat options") .accessibilityLabel("Chat options")
} }
@@ -60,22 +73,32 @@ struct NativeChatView: View {
} }
} }
.navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) } .navigationDestination(for: String.self) { _ in ChatDetailView(store: store, talk: talk) }
.sheet(isPresented: $showLibrary) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) } .sheet(isPresented: $showLibrary) {
ChatLibraryView(store: store.features, initialDestination: libraryDestination, selectedAttachmentIds: store.selectedAttachmentIds, onUseAttachment: { item in
guard await store.queueAttachment(item, expectedDraftGeneration: attachmentGeneration) else { return false }
if let id = store.selected?.id { path = [id] }
return true
}, onRemoveAttachment: { store.selectedAttachmentIds.remove($0) })
}
.sheet(isPresented: $showSecurity) { ChatSecurityView(serverURL: serverURL, accountName: accountName, logout: logout) }
.sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) } .sheet(isPresented: $showPreferences) { ChatPreferencesView(store: store) }
.sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) } .sheet(isPresented: $showWorkspaces) { WorkspaceManagerView(store: store) }
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) { .alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
TextField("Title", text: $title) TextField("Title", text: $title)
Button("Cancel", role: .cancel) { rename = nil } Button("Cancel", role: .cancel) { rename = nil }
Button("Save") { if let rename { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } } Button("Save") { if let rename, !voiceConversationLocked(rename.id) { Task { await store.patch(rename, changes: ["title": title]); if store.error == nil { self.rename = nil } } } }
.disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
.confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) { .confirmationDialog("Delete this chat?", isPresented: Binding(get: { delete != nil }, set: { if !$0 { delete = nil } }), titleVisibility: .visible) {
Button("Delete chat", role: .destructive) { if let delete { Task { await store.delete(delete) } }; delete = nil } Button("Delete chat", role: .destructive) { if let delete, !voiceConversationLocked(delete.id) { Task { await store.delete(delete) } }; delete = nil }
Button("Cancel", role: .cancel) { delete = nil } Button("Cancel", role: .cancel) { delete = nil }
} message: { Text("This removes the conversation, not your account memory.") } } message: { Text("This removes the conversation, not your account memory.") }
} }
} }
private var visible: [HopeConversation] { store.conversations.filter { workspace == nil || $0.workspaceId == workspace } } private var visible: [HopeConversation] { store.conversations }
private func openLibrary(_ destination: ChatLibraryDestination) {
libraryDestination = destination; attachmentGeneration = store.draftGeneration; showLibrary = true
}
private var scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") } private var scopeLabel: String { archived ? "Archived chats" : (workspace.flatMap { id in store.workspaces.first(where: { $0.id == id })?.name } ?? "All chats") }
private var emptyTitle: String { private var emptyTitle: String {
if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" } if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matching chats" }
@@ -90,9 +113,10 @@ struct NativeChatView: View {
} }
private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } } private var pinned: [HopeConversation] { visible.filter(\.pinned).sorted { $0.pinRank < $1.pinRank } }
private var recent: [HopeConversation] { visible.filter { !$0.pinned } } private var recent: [HopeConversation] { visible.filter { !$0.pinned } }
private func refresh() async { await store.load(query: query, archived: archived) } private func refresh() async { await store.load(query: query, archived: archived, workspace: workspace) }
@ViewBuilder private func rows(_ items: [HopeConversation]) -> some View { @ViewBuilder private func rows(_ items: [HopeConversation]) -> some View {
ForEach(items) { conversation in ForEach(items) { conversation in
HStack(alignment: .center, spacing: 8) {
Button { Button {
Task { await store.open(conversation); path = [conversation.id] } Task { await store.open(conversation); path = [conversation.id] }
} label: { } label: {
@@ -104,17 +128,62 @@ struct NativeChatView: View {
}.font(.caption).foregroundStyle(.secondary) }.font(.caption).foregroundStyle(.secondary)
}.frame(minHeight: 44, alignment: .leading) }.frame(minHeight: 44, alignment: .leading)
} }
.contextMenu { .buttonStyle(.plain)
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } } .frame(maxWidth: .infinity, alignment: .leading)
.contextMenu { conversationActions(conversation) }
Menu { conversationActions(conversation) } label: {
Image(systemName: "ellipsis").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
}
.accessibilityLabel("Actions for \(conversation.title)")
.disabled(!store.canMutate(conversation) || voiceConversationLocked(conversation.id))
}
}
}
@ViewBuilder private func conversationActions(_ conversation: HopeConversation) -> some View {
Group {
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation } Button("Rename", systemImage: "pencil") { title = conversation.title; rename = conversation }
Menu("Move to workspace") { Menu("Move to workspace") {
Button("Outside workspaces") { Task { await store.patch(conversation, changes: ["workspace_id": NSNull()]) } } Button("Outside workspaces") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["workspace_id": NSNull()]) } }
ForEach(store.workspaces) { space in Button(space.name) { Task { await store.patch(conversation, changes: ["workspace_id": space.id]) } } } ForEach(store.workspaces) { space in Button(space.name) { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["workspace_id": space.id]) } } }
} }
Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { await store.patch(conversation, changes: ["archived": !conversation.archived]) } } Button(conversation.archived ? "Unarchive" : "Archive", systemImage: "archivebox") { Task { guard !voiceConversationLocked(conversation.id) else { return }; await store.patch(conversation, changes: ["archived": !conversation.archived]) } }
Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation } Button("Delete", systemImage: "trash", role: .destructive) { delete = conversation }
}.disabled(!store.canMutate(conversation) || voiceConversationLocked(conversation.id))
} }
} }
struct ChatSecurityView: View {
let serverURL: String
let accountName: String
let logout: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var confirmSignOut = false
var body: some View {
NavigationStack {
Form {
Section {
LabeledContent("Connected server", value: URL(string: serverURL)?.host ?? serverURL)
LabeledContent("Connection", value: "HTTPS · Encrypted in transit")
LabeledContent("Signed-in account", value: accountName)
} header: { Text("Your connection") } footer: {
Text("Hope uses this server for your private account. The app checks the configured HTTPS certificate; changing servers signs you out.")
}
Section {
Text("Your sign-in is stored securely in this iPhone’s Keychain. Signing out removes local account access and ends the current session; it does not delete conversations, files, or saved memory on your server.")
Button("Sign out of this iPhone", role: .destructive) { confirmSignOut = true }
} header: { Text("Session access") }
Section("Password and account access") {
Text("Password resets are managed by your Hope administrator. Device-session lists and two-factor settings are not available in this app.")
}
}
.navigationTitle("Security")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.confirmationDialog("Sign out of Hope?", isPresented: $confirmSignOut, titleVisibility: .visible) {
Button("Sign out", role: .destructive) { Task { dismiss(); await logout() } }
} message: { Text("Your account data stays on the server. You’ll need to sign in again on this iPhone.") }
}
} }
} }
@@ -125,6 +194,8 @@ struct ChatDetailView: View {
@State private var editedText = "" @State private var editedText = ""
@State private var follow = false @State private var follow = false
@State private var library = false @State private var library = false
@State private var attachmentGeneration = 0
@FocusState private var composerFocused: Bool
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
@@ -182,16 +253,19 @@ struct ChatDetailView: View {
if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) } if store.researchAvailable { Toggle("Research with cited sources", isOn: $store.research).font(.footnote) }
if !store.selectedAttachmentIds.isEmpty { if !store.selectedAttachmentIds.isEmpty {
AttachmentSelectionSummary(store: store) AttachmentSelectionSummary(store: store)
if !store.canSendAttachments { Text("A queued file is no longer supported. Remove it or retry file support in Library.").font(.footnote).foregroundStyle(.secondary) }
} }
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
Button { library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files") Button { attachmentGeneration = store.draftGeneration; library = true } label: { Image(systemName: "paperclip").frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Attach files")
TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft") TextField("Message Hope", text: $store.draft, axis: .vertical).lineLimit(1...7).textFieldStyle(.roundedBorder).accessibilityIdentifier("chat-draft").focused($composerFocused)
if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) } if store.activeRun != nil { Button("Stop", systemImage: "stop.fill") { Task { await store.stop() } }.frame(minWidth: 44, minHeight: 44) }
else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } else { Button { follow = true; Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title).frame(minWidth: 44, minHeight: 44) }.accessibilityLabel("Send message").disabled(store.busy || !store.canSendAttachments || store.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
} }
}.padding(16).background(.regularMaterial) }.padding(16).background(.regularMaterial)
} }
.sheet(isPresented: $library) { ChatLibraryView(store: store.features, selectedAttachmentIds: $store.selectedAttachmentIds) } .sheet(isPresented: $library, onDismiss: { if !store.selectedAttachmentIds.isEmpty { composerFocused = true } }) {
ChatLibraryView(store: store.features, selectedAttachmentIds: store.selectedAttachmentIds, onUseAttachment: { await store.queueAttachment($0, expectedDraftGeneration: attachmentGeneration) }, onRemoveAttachment: { store.selectedAttachmentIds.remove($0) })
}
.sheet(item: $editing) { message in .sheet(item: $editing) { message in
NavigationStack { NavigationStack {
Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) } Form { Section("Edit message") { TextEditor(text: $editedText).frame(minHeight: 200) }; Text("A new branch preserves the original conversation.").font(.footnote).foregroundStyle(.secondary) }
@@ -205,15 +279,15 @@ struct ChatDetailView: View {
private struct AttachmentSelectionSummary: View { private struct AttachmentSelectionSummary: View {
@ObservedObject var store: ChatStore @ObservedObject var store: ChatStore
var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold)) Text("Attached to next message (\(store.selectedAttachmentIds.count)/8)").font(.caption.weight(.semibold))
ForEach(selectedAttachments) { item in ForEach(store.selectedAttachmentIds.sorted(), id: \.self) { id in
HStack { HStack {
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1) Label(store.queuedAttachment(id)?.filename ?? "Unavailable attachment", systemImage: "doc").font(.caption).lineLimit(1)
Spacer() Spacer()
Button("Remove") { store.selectedAttachmentIds.remove(item.id) }.font(.caption).frame(minHeight: 36) Button("Remove") { store.selectedAttachmentIds.remove(id) }.font(.caption).frame(minWidth: 44, minHeight: 44)
.accessibilityLabel("Remove \(store.queuedAttachment(id)?.filename ?? "unavailable attachment")")
} }
} }
} }
@@ -60,6 +60,8 @@ struct ConversationView: View {
selectedTab = 0 selectedTab = 0
}, logout: { }, logout: {
await auth.logout { chat.close(); await session.disconnect() } await auth.logout { chat.close(); await session.disconnect() }
}, serverURL: activeServerURL, accountName: user.displayName ?? user.username, voiceConversationLocked: { id in
id == session.boundConversationID && session.state != .idle && !session.state.isFailed
}) })
.tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") } .tabItem { Label("Chats", systemImage: "bubble.left.and.text.bubble.right") }
.tag(2) .tag(2)
@@ -83,7 +85,7 @@ struct ConversationView: View {
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") } .tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
.tag(0) .tag(0)
ActivityTab(taskStore: session.taskStore, settings: settings, session: session) ActivityTab(taskStore: session.taskStore, settings: settings, session: session, features: chat.features)
.tag(1) .tag(1)
} }
.tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal)) .tint(HopeTheme.color(.accent, palette: HopePalette(rawValue: chat.preferences.palette) ?? .opal))
@@ -141,10 +143,17 @@ private struct ActivityTab: View {
@ObservedObject var taskStore: TaskStore @ObservedObject var taskStore: TaskStore
@ObservedObject var settings: SettingsStore @ObservedObject var settings: SettingsStore
@ObservedObject var session: VoiceSession @ObservedObject var session: VoiceSession
@ObservedObject var features: ChatFeatureStore
@State private var showSchedules = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
TaskPanelView(taskStore: taskStore) TaskPanelView(taskStore: taskStore)
.safeAreaInset(edge: .top) {
Button("Scheduled tasks", systemImage: "calendar") { showSchedules = true }
.frame(maxWidth: .infinity, minHeight: 44).padding(.horizontal).background(.regularMaterial)
}
.sheet(isPresented: $showSchedules) { ChatLibraryView(store: features, initialDestination: .tasks) }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
NavigationLink { NavigationLink {
+3
View File
@@ -112,6 +112,9 @@ final class VoiceSession: ObservableObject {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
/// Identity bound to Talk, independent of the current Chats selection.
var boundConversationID: String? { tokenClient.conversationID }
/// Explicit chat-to-Talk navigation tears down the prior room first. /// Explicit chat-to-Talk navigation tears down the prior room first.
func bindConversation(_ id: String?) async { func bindConversation(_ id: String?) async {
await disconnect() await disconnect()
+156
View File
@@ -0,0 +1,156 @@
import XCTest
@testable import Voice
private final class FeatureParityURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (Int, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let (status, data) = try Self.handler!(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
private final class SuspendedFeatureTransport: ChatTransport {
var handler: (@MainActor (String) async throws -> Data)?
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { try await handler!(path) }
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
func cancel() {}
}
@MainActor
final class ChatFeatureParityTests: XCTestCase {
private func makeStore() throws -> (ChatFeatureStore, ChatAPI) {
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { throw ChatFailure.invalidResponse }
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [FeatureParityURLProtocol.self]
let api = ChatAPI(config: config, token: "offline-fixture", session: URLSession(configuration: configuration))
return (ChatFeatureStore(api: api), api)
}
private func attachment(status: String = "ready", mime: String = "text/plain") -> ChatAttachment {
ChatAttachment(id: String(repeating: "a", count: 32), filename: "notes.txt", mime: mime, size: 5, extractionStatus: status, createdAt: 1)
}
private func envelope(_ item: ChatAttachment) throws -> Data {
try JSONSerialization.data(withJSONObject: ["attachment": ["id": item.id, "filename": item.filename, "mime": item.mime, "size": item.size, "extraction_status": item.extractionStatus, "created_at": item.createdAt]])
}
func testMetadataRevalidationRejectsUnavailableAndDeletedFiles() async throws {
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
let old = attachment()
let unavailable = try envelope(attachment(status: "unavailable"))
var metadataStatus = 200
FeatureParityURLProtocol.handler = { request in
if request.url?.path == "/api/capabilities" { return (200, Data("{\"features\":{\"attachments\":true}}".utf8)) }
XCTAssertEqual(request.url?.path, "/api/attachments/\(old.id)")
return (metadataStatus, unavailable)
}
let blocked = await store.validatedAttachment(old)
XCTAssertNil(blocked)
XCTAssertTrue(store.error?.contains("unavailable") == true)
metadataStatus = 404
let deleted = await store.validatedAttachment(old)
XCTAssertNil(deleted)
XCTAssertTrue(store.error?.contains("no longer available") == true)
}
func testCapabilityRefreshFailsClosedAndImagesCannotUpload() async throws {
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
store.enabled = ["attachments", "image_input"]
FeatureParityURLProtocol.handler = { _ in (503, Data()) }
let refreshed = await store.refreshCapabilities()
XCTAssertFalse(refreshed)
XCTAssertTrue(store.enabled.isEmpty)
FeatureParityURLProtocol.handler = { request in
XCTAssertEqual(request.url?.path, "/api/capabilities", "Unqualified images must never reach upload")
return (200, Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8))
}
let uploaded = await store.upload(Data([1, 2, 3]), filename: "photo.jpg", mime: "image/jpeg")
XCTAssertNil(uploaded)
XCTAssertTrue(store.error?.contains("Image input") == true)
}
func testLateCapabilityResponseCannotRestoreOldImageQualification() async throws {
let api = SuspendedFeatureTransport()
let store = ChatFeatureStore(api: api)
let firstStarted = expectation(description: "First capability request suspended")
var firstResponse: CheckedContinuation<Data, Error>?
var requests = 0
api.handler = { _ in
requests += 1
if requests == 1 {
return try await withCheckedThrowingContinuation { continuation in
firstResponse = continuation
firstStarted.fulfill()
}
}
return Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8)
}
let first = Task { await store.refreshCapabilities() }
await fulfillment(of: [firstStarted], timeout: 2)
let latest = await store.refreshCapabilities()
XCTAssertTrue(latest)
firstResponse?.resume(returning: Data("{\"features\":{\"attachments\":true,\"image_input\":true}}".utf8))
let stale = await first.value
XCTAssertFalse(stale)
XCTAssertFalse(store.enabled.contains("image_input"))
}
func testBlockedStatusesIncludeUnknownAndImageQualification() {
for status in ["failed", "encrypted", "unavailable", "pending"] {
XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: status), imageInput: true))
}
XCTAssertNil(ChatFeatureStore.blockedReason(attachment(status: "truncated"), imageInput: false))
XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: "image", mime: "image/png"), imageInput: false))
}
func testPausedTaskAndOptionalRepeatUseContractFields() async throws {
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
FeatureParityURLProtocol.handler = { request in
XCTAssertEqual(request.url?.path, "/api/tasks")
XCTAssertEqual(request.httpMethod, "POST")
var bytes = request.httpBody ?? Data()
if bytes.isEmpty, let stream = request.httpBodyStream {
stream.open(); defer { stream.close() }
var buffer = [UInt8](repeating: 0, count: 1024)
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
}
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
XCTAssertEqual(body["paused"] as? Bool, true)
XCTAssertEqual(body["repeat"] as? Int, 3)
XCTAssertEqual(body["schedule"] as? String, "0 9 * * *")
return (201, Data("{\"task\":{\"id\":\"aaaaaaaaaaaa\",\"enabled\":false,\"repeat\":3}}".utf8))
}
await store.saveTask(nil, name: "Daily", schedule: "0 9 * * *", prompt: "Review", repeatCount: 3, paused: true)
XCTAssertNil(store.error)
XCTAssertEqual(store.tasks.first?.enabled, false)
XCTAssertEqual(store.tasks.first?.repeat, 3)
}
func testMemoryConflictRequiresExplicitLatestRevisionAndKeepsDraft() async throws {
let api = OfflineChatAPI()
let store = ChatFeatureStore(api: api)
await store.load()
let original = try XCTUnwrap(store.memory.first)
let first = await store.saveMemory(original, content: "Server update")
XCTAssertTrue(first)
let localDraft = "Keep my draft"
let stale = await store.saveMemory(original, content: localDraft)
XCTAssertFalse(stale)
XCTAssertTrue(store.error?.contains("Compare") == true)
let fetched = await store.latestMemory(original)
let latest = try XCTUnwrap(fetched)
XCTAssertEqual(latest.content, "Server update")
XCTAssertEqual(localDraft, "Keep my draft")
let accepted = await store.saveMemory(latest, content: localDraft)
XCTAssertTrue(accepted)
XCTAssertEqual(store.memory.first?.content, localDraft)
}
func testClosedStoreCannotReloadOrPreviewAccountData() async throws {
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
FeatureParityURLProtocol.handler = { _ in XCTFail("Closed account must not request private data"); return (200, Data()) }
store.close()
await store.load()
await store.preview(attachment())
XCTAssertTrue(store.enabled.isEmpty)
XCTAssertNil(store.previewAttachment)
}
}
+207
View File
@@ -0,0 +1,207 @@
import XCTest
@testable import Voice
private final class ParityChatAPI: ChatTransport {
struct Request { let path: String; let method: String; let body: [String: Any]?; let query: [URLQueryItem] }
var requests: [Request] = []
var handler: ((Request) async throws -> Data?)?
private let fallback = OfflineChatAPI()
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data {
let request = Request(path: path, method: method, body: body, query: query)
requests.append(request)
if let data = try await handler?(request) { return data }
return try await fallback.request(path, method: method, body: body, query: query)
}
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
func cancel() {}
}
@MainActor
final class ChatParityTests: XCTestCase {
private func conversation(_ id: String, workspace: String? = nil, revision: Int = 1) -> HopeConversation {
HopeConversation(id: id, title: id, workspaceId: workspace, pinned: false, pinRank: 0, archived: false, temporary: false, revision: revision, createdAt: 1, updatedAt: 1)
}
private func encoded(_ value: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: value) }
private func projection(_ conversation: HopeConversation) throws -> Any {
let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase
return try JSONSerialization.jsonObject(with: encoder.encode(conversation))
}
private func attachment(_ id: String, mime: String = "text/plain", status: String = "ready") -> ChatAttachment {
ChatAttachment(id: id, filename: "notes.txt", mime: mime, size: 12, extractionStatus: status, createdAt: 1)
}
func testWorkspaceFilterPrecedesPagingAndScopeChangeDropsCursor() async throws {
let api = ParityChatAPI(); let store = ChatStore(api: api)
api.handler = { request in
guard request.path == "api/conversations" else { return nil }
return Data("{\"conversations\":[],\"next_cursor\":\"page-two\"}".utf8)
}
await store.load(query: "search", archived: true, workspace: "writing")
await store.load(query: "search", archived: true, workspace: "writing", more: true)
await store.load(query: "search", archived: true, workspace: "different", more: true)
let lists = api.requests.filter { $0.path == "api/conversations" }
XCTAssertEqual(lists.count, 3)
XCTAssertEqual(lists[0].query.first { $0.name == "workspace_id" }?.value, "writing")
XCTAssertEqual(lists[1].query.first { $0.name == "cursor" }?.value, "page-two")
XCTAssertNil(lists[2].query.first { $0.name == "cursor" })
}
func testSyncPreservesTwoLoadedPagesAndContinuationCursor() async throws {
let api = ParityChatAPI(); let store = ChatStore(api: api)
let first = conversation("first", workspace: "writing")
let older = conversation("older", workspace: "writing")
var changed = older; changed.title = "Renamed remotely"; changed.revision = 2
api.handler = { [self] request in
if request.path == "api/conversations" {
let secondPage = request.query.contains { $0.name == "cursor" && $0.value == "page-two" }
return try encoded(["conversations": [projection(secondPage ? older : first)], "next_cursor": secondPage ? "page-three" : "page-two"])
}
if request.path == "api/sync" {
return try encoded(["changes": [["id": changed.id, "kind": "conversation", "deleted": false, "data": projection(changed)]], "cursor": 1])
}
return nil
}
await store.load(query: "message match", workspace: "writing")
await store.load(query: "message match", workspace: "writing", more: true)
XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id])
XCTAssertEqual(store.nextCursor, "page-three")
await store.synchronize()
XCTAssertEqual(store.conversations.map(\.id), [first.id, older.id])
XCTAssertEqual(store.conversations.last?.title, "Renamed remotely")
XCTAssertEqual(store.nextCursor, "page-three")
XCTAssertEqual(api.requests.filter { $0.path == "api/conversations" }.count, 2)
}
func testOtherRowMutationPreservesComposerAndActiveRunAndScope() async throws {
let api = ParityChatAPI(); let store = ChatStore(api: api)
let selected = conversation("selected", workspace: "writing")
let other = conversation("other", workspace: "writing")
let moved = conversation("other", workspace: "elsewhere", revision: 2)
api.handler = { [self] request in
if request.method == "PATCH" { return try encoded(["conversation": projection(moved)]) }
if request.path == "api/conversations" { return Data("{\"conversations\":[],\"next_cursor\":null}".utf8) }
return nil
}
await store.load(workspace: "writing")
store.selected = selected; store.conversations = [selected, other]; store.draft = "Keep draft"
store.messages = [HopeMessage(id: "m", conversationId: selected.id, role: "assistant", content: "Streaming", status: "streaming", runId: "r", createdAt: 1)]
store.runs = [HopeRun(id: "r", conversationId: selected.id, status: "running", messageId: "m", createdAt: 1, updatedAt: 1, error: nil)]
XCTAssertFalse(store.canMutate(selected)); XCTAssertTrue(store.canMutate(other))
await store.patch(other, changes: ["workspace_id": "elsewhere"])
XCTAssertEqual(store.selected, selected); XCTAssertEqual(store.draft, "Keep draft")
XCTAssertEqual(store.messages.first?.content, "Streaming"); XCTAssertEqual(store.activeRun?.id, "r")
XCTAssertEqual(api.requests.last { $0.path == "api/conversations" }?.query.first { $0.name == "workspace_id" }?.value, "writing")
XCTAssertFalse(store.conversations.contains { $0.id == other.id })
}
func testMutationCompletionSurvivesNavigationButNotAccountClose() async throws {
for close in [false, true] {
let api = ParityChatAPI(); let store = ChatStore(api: api)
await store.create(); let first = try XCTUnwrap(store.selected)
await store.create(); let second = try XCTUnwrap(store.selected)
var finish: CheckedContinuation<Void, Never>?
var started: CheckedContinuation<Void, Never>?
api.handler = { request in
if request.method == "PATCH" {
started?.resume(); started = nil
await withCheckedContinuation { finish = $0 }
}
return nil
}
let operation = Task { await store.patch(first, changes: ["title": "Renamed"]) }
await withCheckedContinuation { started = $0 }
if close { store.close() } else { await store.open(first); await store.open(second) }
finish?.resume(); await operation.value
if close { XCTAssertTrue(store.conversations.isEmpty); XCTAssertNil(store.selected) }
else { XCTAssertEqual(store.selected?.id, second.id); XCTAssertEqual(store.conversations.first { $0.id == first.id }?.title, "Renamed") }
}
}
func testStaleRevisionRefreshesMetadataOnlyAndRetryIsActionable() async throws {
let store = ChatStore(api: OfflineChatAPI())
await store.create(); let stale = try XCTUnwrap(store.selected)
await store.patch(stale, changes: ["title": "Latest"])
store.draft = "Private unsent draft"
await store.patch(stale, changes: ["title": "Stale overwrite"])
XCTAssertEqual(store.selected?.title, "Latest"); XCTAssertEqual(store.draft, "Private unsent draft")
XCTAssertTrue(store.error?.contains("latest details are loaded") == true)
await store.patch(try XCTUnwrap(store.selected), changes: ["title": "Retry"])
XCTAssertEqual(store.selected?.title, "Retry")
}
func testAttachmentQueueBindsComposerAndRejectsPendingAndUnsupportedFiles() async throws {
let store = ChatStore(api: OfflineChatAPI()); await store.create()
store.features.enabled = ["attachments"]
let epoch = store.draftGeneration
let accepted = await store.queueAttachment(attachment("a"), expectedDraftGeneration: epoch)
XCTAssertTrue(accepted)
let image = await store.queueAttachment(attachment("image", mime: "image/png", status: "image"), expectedDraftGeneration: epoch)
XCTAssertFalse(image)
let failed = await store.queueAttachment(attachment("failed", status: "encrypted"), expectedDraftGeneration: epoch)
XCTAssertFalse(failed)
for i in 1...7 { _ = await store.queueAttachment(attachment("extra-\(i)"), expectedDraftGeneration: epoch) }
let excess = await store.queueAttachment(attachment("ninth"), expectedDraftGeneration: epoch)
XCTAssertFalse(excess); XCTAssertEqual(store.selectedAttachmentIds.count, 8)
store.busy = true
let pending = await store.queueAttachment(attachment("pending"), expectedDraftGeneration: epoch)
XCTAssertFalse(pending); store.busy = false
await store.create()
let stale = await store.queueAttachment(attachment("stale"), expectedDraftGeneration: epoch)
XCTAssertFalse(stale); XCTAssertTrue(store.selectedAttachmentIds.isEmpty)
store.close()
let closed = await store.queueAttachment(attachment("closed"))
XCTAssertFalse(closed)
}
func testUnavailableAttachmentCanBeRemovedAndFreshQueueWins() async throws {
let store = ChatStore(api: OfflineChatAPI()); await store.create()
store.features.enabled = ["attachments"]
let current = attachment("a")
let accepted = await store.queueAttachment(current)
XCTAssertTrue(accepted)
store.features.attachments = [attachment("a", status: "failed")]
XCTAssertEqual(store.queuedAttachment("a")?.extractionStatus, "ready")
XCTAssertTrue(store.canSendAttachments)
store.selectedAttachmentIds.insert("missing")
XCTAssertNil(store.queuedAttachment("missing"))
XCTAssertFalse(store.canSendAttachments)
store.selectedAttachmentIds.remove("missing")
XCTAssertTrue(store.canSendAttachments)
store.close()
}
func testEditAndRegenerateResendExactOriginalAttachmentIDs() async throws {
for regenerate in [false, true] {
let api = ParityChatAPI(); let store = ChatStore(api: api)
await store.create(); store.draft = "Original"; await store.send()
let ids = ["00000000000000000000000000000001", "00000000000000000000000000000002"]
store.messages[0].attachments = ids
api.handler = { request in
if request.path.hasPrefix("api/attachments/") {
let id = String(request.path.split(separator: "/").last!)
return Data("{\"attachment\":{\"id\":\"\(id)\",\"filename\":\"notes.txt\",\"mime\":\"text/plain\",\"size\":12,\"extraction_status\":\"ready\",\"created_at\":1}}".utf8)
}
return nil
}
let target = try XCTUnwrap(regenerate ? store.messages.last : store.messages.first)
await store.branch(target, edited: regenerate ? nil : "Edited", regenerate: regenerate)
let resent = try XCTUnwrap(api.requests.last { $0.path.hasSuffix("/messages") })
XCTAssertEqual(resent.body?["attachment_ids"] as? [String], ids)
}
}
func testModelSaveRefreshFailureDisablesImageCapability() async throws {
let api = ParityChatAPI(); let store = ChatStore(api: api)
await store.create()
store.features.enabled = ["attachments", "image_input"]
let image = attachment("qualified-image", mime: "image/png", status: "image")
let queued = await store.queueAttachment(image, expectedDraftGeneration: store.draftGeneration)
XCTAssertTrue(queued); XCTAssertTrue(store.canSendAttachments)
store.draft = "Keep this image draft"
api.handler = { request in
if request.path == "api/settings/model" { return Data("{\"settings\":{\"model\":\"auto\",\"thinking_level\":\"off\"},\"message\":null}".utf8) }
if request.path == "api/capabilities" { throw ChatFailure.status(503) }
return nil
}
await store.saveModel()
XCTAssertFalse(store.features.enabled.contains("image_input"))
XCTAssertTrue(api.requests.contains { $0.path == "api/capabilities" })
XCTAssertFalse(store.canSendAttachments)
await store.send()
XCTAssertFalse(api.requests.contains { $0.path.hasSuffix("/messages") })
XCTAssertEqual(store.draft, "Keep this image draft")
XCTAssertEqual(store.selectedAttachmentIds, Set([image.id]))
XCTAssertTrue(store.error?.contains("current model") == true)
}
}
+48 -7
View File
@@ -18,7 +18,7 @@ final class ChatUITests: XCTestCase {
XCTAssertFalse(app.buttons["End conversation"].exists) XCTAssertFalse(app.buttons["End conversation"].exists)
app.navigationBars.buttons.element(boundBy: 0).tap() app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Chat options"].tap() app.buttons["Chat options"].tap()
app.buttons["Sign out"].tap() app.buttons["Settings"].tap(); app.buttons["Sign out"].tap()
XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10)) XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
XCTAssertFalse(app.staticTexts["Native offline text"].exists) XCTAssertFalse(app.staticTexts["Native offline text"].exists)
} }
@@ -27,9 +27,9 @@ final class ChatUITests: XCTestCase {
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch() app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap() app.buttons["Chat options"].tap(); app.buttons["Library"].tap()
XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["New saved draft"].waitForExistence(timeout: 10))
app.buttons["New artifact"].tap() app.buttons["New saved draft"].tap()
XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10)) XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10))
app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact") app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact")
app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content") app.textViews.firstMatch.tap(); app.textViews.firstMatch.typeText("Native fixture content")
@@ -43,6 +43,47 @@ final class ChatUITests: XCTestCase {
app.buttons["Cancel"].tap() app.buttons["Cancel"].tap()
app.buttons["Done"].tap() app.buttons["Done"].tap()
} }
func testDiscoverableConversationMenuLibraryMemorySecurityAndTasks() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch()
XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
app.buttons["New chat"].tap()
let field = app.textFields["chat-draft"]
XCTAssertTrue(field.waitForExistence(timeout: 10))
field.tap(); field.typeText("Parity fixture")
app.buttons["Send message"].tap()
XCTAssertTrue(app.staticTexts["Offline reply"].waitForExistence(timeout: 10))
app.navigationBars.buttons.element(boundBy: 0).tap()
let actions = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Actions for '")).firstMatch
XCTAssertTrue(actions.waitForExistence(timeout: 10)); actions.tap()
XCTAssertTrue(app.buttons["Pin"].waitForExistence(timeout: 5)); app.buttons["Pin"].tap()
capture(app, "Native visible conversation actions")
app.buttons["Chat options"].tap(); app.buttons["Library"].tap()
XCTAssertTrue(app.buttons["Upload file"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Saved drafts"].exists)
capture(app, "Native Library purpose and upload")
app.buttons["Done"].tap()
app.buttons["Chat options"].tap(); app.buttons["Memory & data"].tap()
XCTAssertTrue(app.buttons["Guidance"].waitForExistence(timeout: 10))
capture(app, "Native Memory and data")
app.buttons["Guidance"].tap()
XCTAssertTrue(app.textViews["Memory draft"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Compare latest server version"].exists)
capture(app, "Native memory revision editor")
app.buttons["Cancel"].tap(); app.buttons["Done"].tap()
app.buttons["Chat options"].tap(); app.buttons["Settings"].tap(); app.buttons["Security"].tap()
XCTAssertTrue(app.staticTexts["Connected server"].waitForExistence(timeout: 10))
capture(app, "Native contextual Security")
app.buttons["Done"].tap()
app.tabBars.buttons["Activity"].tap(); app.buttons["Scheduled tasks"].tap()
XCTAssertTrue(app.buttons["New scheduled task"].waitForExistence(timeout: 10))
app.buttons["New scheduled task"].tap()
XCTAssertTrue(app.textFields["Task name"].waitForExistence(timeout: 10))
XCTAssertTrue(app.switches["Create paused"].exists)
capture(app, "Native scheduled task fields")
}
private func capture(_ app: XCUIApplication, _ name: String) { private func capture(_ app: XCUIApplication, _ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment) let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
} }
@@ -51,7 +92,7 @@ final class ChatUITests: XCTestCase {
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch() app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Manage workspaces"].tap() app.buttons["Chat options"].tap(); app.buttons["Settings"].tap(); app.buttons["Manage workspaces"].tap()
let field = app.textFields["Workspace name"] let field = app.textFields["Workspace name"]
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing") XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing")
app.buttons["Save workspace"].tap() app.buttons["Save workspace"].tap()
@@ -63,7 +104,7 @@ final class ChatUITests: XCTestCase {
app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] app.launchArguments = ["--ui-testing", "--reset-ui-testing", "--ui-chat", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"]
app.launch() app.launch()
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap(); app.buttons["Appearance and model"].tap() app.buttons["Chat options"].tap(); app.buttons["Settings"].tap(); app.buttons["Appearance and model"].tap()
app.buttons.matching(NSPredicate(format: "label CONTAINS 'Appearance' AND label CONTAINS 'System'")).firstMatch.tap(); app.buttons["Dark"].tap() app.buttons.matching(NSPredicate(format: "label CONTAINS 'Appearance' AND label CONTAINS 'System'")).firstMatch.tap(); app.buttons["Dark"].tap()
app.buttons["Save appearance"].tap(); app.buttons["Done"].tap() app.buttons["Save appearance"].tap(); app.buttons["Done"].tap()
capture(app, "Native chat list dark standard") capture(app, "Native chat list dark standard")
@@ -81,7 +122,7 @@ final class ChatUITests: XCTestCase {
app.launch() app.launch()
XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
app.buttons["Chat options"].tap() app.buttons["Chat options"].tap()
app.buttons["Appearance and model"].tap() app.buttons["Settings"].tap(); app.buttons["Appearance and model"].tap()
XCTAssertTrue(app.navigationBars["Chat preferences"].waitForExistence(timeout: 10)) XCTAssertTrue(app.navigationBars["Chat preferences"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Save appearance"].exists) XCTAssertTrue(app.buttons["Save appearance"].exists)
capture(app, "Appearance accessibility size") capture(app, "Appearance accessibility size")
+8
View File
@@ -16,6 +16,7 @@
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; }; 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
3CDB3C606956A4D9906096D5 /* ChatParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */; };
4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */; }; 4AA89EFF10F261A3FCF8C366 /* ChatExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14505F4DA9DB1610B2EEB271 /* ChatExport.swift */; };
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; }; 4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.swift */; };
57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; }; 57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; };
@@ -25,6 +26,7 @@
6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; }; 6488E54A6D93278C04D870E2 /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDA0509C2DF2504AA50CBA9 /* AuthStore.swift */; };
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; }; 70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; };
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; }; 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; };
784F521516838C0FFF168A1D /* ChatFeatureParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */; };
7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; }; 7A8CC686530A5920338EDF5A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 26922A8D6467BD51ED45B63B /* Assets.xcassets */; };
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; }; 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; }; 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
@@ -82,6 +84,7 @@
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; }; 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; }; 41286AD7E2621F77B4F7CC85 /* ChatFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatures.swift; sourceTree = "<group>"; };
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatParityTests.swift; sourceTree = "<group>"; };
536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; }; 536E3E89F4BB09D631744F3F /* KeychainTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStore.swift; sourceTree = "<group>"; };
5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5759EB570DB87BBC121D946D /* VoiceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; }; 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceLiveTests.swift; sourceTree = "<group>"; };
@@ -89,6 +92,7 @@
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; }; 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = "<group>"; };
6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; }; 6CBEDBE8C5065D716DAECDE2 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = "<group>"; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; }; 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFeatureParityTests.swift; sourceTree = "<group>"; };
8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; }; 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = "<group>"; };
87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; }; 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = "<group>"; };
96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; 96278769DD369C629F18E475 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
@@ -245,7 +249,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
262B5A712708421C35ED6AC0 /* AuthTests.swift */, 262B5A712708421C35ED6AC0 /* AuthTests.swift */,
6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */,
F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */, F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */,
4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */,
B5B373E4A57214C0D289536B /* ChatTests.swift */, B5B373E4A57214C0D289536B /* ChatTests.swift */,
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */, C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */, BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
@@ -449,7 +455,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */, 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
784F521516838C0FFF168A1D /* ChatFeatureParityTests.swift in Sources */,
A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */, A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */,
3CDB3C606956A4D9906096D5 /* ChatParityTests.swift in Sources */,
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */, C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */,
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */, B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */,
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */, FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
+19 -3
View File
@@ -1,6 +1,20 @@
# Project status and handoff # Project status and handoff
Last updated: 2026-10-04 (verified monorepo import; no new live chat/device qualification). Last updated: 2026-10-04 (native parity qualified; signed update installed and launched on physical iPhone).
## Native parity and physical update — 2026-10-04
Implemented visible 44pt conversation action menus (pin/rename/move/archive/delete), server-side workspace scope, independent row writes preserving other drafts/runs, and locks against the actual Talk-bound conversation. Library separates private files from saved drafts; upload/reuse explicitly queues to the original composer, never auto-sends. Missing historical files remain removable. Sending fails closed after model/capability changes; edit/regenerate preserves original attachment IDs with metadata validation.
Scheduled tasks are discoverable from Chats and Activity, with instructions, optional repeat count, and Create paused enabled by default. Memory explains Guidance/About you versus history, retains conflict drafts, requires explicit revision review/acceptance, and confirms clearing. Security shows current server/account, Keychain/session semantics, and confirmed sign-out without fabricated 2FA/device management. The primary menu uses compact Settings/Filter submenus to remain reachable at accessibility text sizes.
Independent source review identified and resolved actual voice-binding locks, background sync collapsing paginated lists, edits during memory writes, unsupported-image sending, and missing-file removal recovery. Added 16 focused parity tests. XcodeGen regenerated the project.
Verification: iPhone 17 Pro simulator, iOS 26.4: 28 XCTest unit cases passed with one explicit live-chat skip, plus 60 Swift Testing cases passed; 13 UI tests passed (six chat and seven existing voice UI tests). Final task-label/paused-default polish was then rebuilt and the complete parity navigation UI case passed again. Artifacts: `/tmp/hope-ios-parity-final.xcresult`, `/tmp/hope-ios-parity-last-polish.xcresult`, exported screenshots under `/tmp/hope-ios-parity-final-screenshots` and `/tmp/hope-ios-parity-polish-screenshots`. Visually inspected Library, memory, Security and task editor screenshots; standard light/dark and accessibility sizes were exercised.
Signed generic-iOS build succeeded using existing team/configuration; `codesign --verify --deep --strict` passed. App: `/tmp/hope-ios-parity-phone/Build/Products/Debug-iphoneos/Voice.app`. Initial install failed while phone was unavailable. After the user connected/unlocked the phone, CoreDevice installed `dev.shane.voice` on the physical iPhone 14 Pro Max and launched it successfully at 10:39. Receipts: `/tmp/hope-ios-parity-install-usb.json`, `/tmp/hope-ios-parity-phone-launch.json` and matching `.log` files. Installed as an update without uninstall, credentials reset, or UI-test launch arguments.
Scope: this proves signed build, physical installation and app launch—not authenticated live feature API behavior or physical microphone/STT/TTS. No live test opt-in, NAS account data inspection, backend redeployment or runtime configuration changes were performed for this native task. Unrelated concurrent web/test/qualification edits and local design artifacts were excluded from native staging.
## Repository migration ## Repository migration
@@ -10,8 +24,10 @@ active development checkout; old sibling paths and separate push guidance below
are historical. See [migration record](../../docs/IOS_MONOREPO_MIGRATION.md) for are historical. See [migration record](../../docs/IOS_MONOREPO_MIGRATION.md) for
ancestry, build paths, compatibility scope and identified functional follow-ups. ancestry, build paths, compatibility scope and identified functional follow-ups.
Root theme generation now checks the native artifact; obsolete native backend Root theme generation now checks the native artifact; obsolete native backend
deploy tooling fails closed. The old repository is archived only after verified deploy tooling fails closed. Migration merge `d735ee7` was pushed and remote
publication of this history-preserving import. ancestry verified; the old repository was then archived using `tea api`, with a
separate GET confirming `archived: true`. Native relocated tests passed 72 cases
with one live-chat skip; backend offline selection passed 147 cases.
Reusable implementation, testing, device and deployment lessons are in Reusable implementation, testing, device and deployment lessons are in
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install [ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
+155 -6
View File
@@ -5,7 +5,7 @@ import unittest
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
MOCK = r'''() => { MOCK = r'''() => {
window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false; window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false; window.uploadError = ''; window.extraction = 'ready'; window.file = {id:'c'.repeat(32),filename:'notes.txt',mime:'text/plain',size:5,extraction_status:'ready'};
window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true, window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true,
attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608}; attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608};
window.artifact = {id:'a'.repeat(32),title:'Draft <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'}; window.artifact = {id:'a'.repeat(32),title:'Draft <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'};
@@ -16,10 +16,11 @@ MOCK = r'''() => {
binary:options.body instanceof File,headers:options.headers}); binary:options.body instanceof File,headers:options.headers});
if (path==='/api/capabilities') return {features:capabilities}; if (path==='/api/capabilities') return {features:capabilities};
if (path==='/api/attachments') { if (path==='/api/attachments') {
if(options.method==='GET') return {attachments:[{id:'c'.repeat(32),filename:'notes.txt',size:5,extraction_status:'ready'}]}; if(options.method==='GET') return {attachments:[file]};
if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError')))); if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError'))));
return {attachment:{id:'c'.repeat(32),filename:options.body.name,extraction_status:'ready'}}; if(uploadError) throw new Error(uploadError); return {attachment:{id:'c'.repeat(32),filename:options.body.name,mime:options.body.type,size:options.body.size,extraction_status:extraction}};
} }
if(path.startsWith('/api/attachments/')) return {attachment:file};
if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};} if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};}
if(path.startsWith('/api/artifacts/')) { if(path.startsWith('/api/artifacts/')) {
if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};} if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};}
@@ -55,17 +56,165 @@ class ChatFeaturesWebTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.page = self.browser.new_page() self.page = self.browser.new_page()
self.page.set_content('<html><body><aside id="chatSidebar"></aside><form id="primaryComposer"><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>') self.page.set_content('<html><body><aside id="chatSidebar"></aside><dialog id="libraryDialog"><div id="libraryContent"></div></dialog><form id="primaryComposer"><textarea id="primaryInput"></textarea><button id="sendMock" type="submit">Send</button><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
self.page.evaluate(MOCK) self.page.evaluate(MOCK)
self.page.add_script_tag(path=str(ROOT / 'web/features.js')) self.page.add_script_tag(path=str(ROOT / 'web/features.js'))
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false') self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
self.page.evaluate('document.getElementById("primaryComposer").addEventListener("submit",e=>{e.preventDefault();calls.push({path:"/api/conversations/mock/messages",method:"POST",body:{attachment_ids:HopeFeatures.attachmentIDs()}});HopeFeatures.clearAttachmentsAfterSend();})')
def tearDown(self): def tearDown(self):
self.page.close() self.page.close()
def open_panel(self, name): def open_panel(self, name):
self.page.evaluate('document.getElementById("libraryDialog").showModal()')
self.page.get_by_text(name, exact=True).click() self.page.get_by_text(name, exact=True).click()
def test_library_upload_queues_without_send(self):
self.open_panel('Files')
with self.page.expect_file_chooser() as chooser:
self.page.get_by_role('button', name='Upload files', exact=True).click()
chooser.value.set_files({'name':'library.txt','mimeType':'text/plain','buffer':b'Library'})
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path.includes("/messages")).length'), 0)
self.assertIn('nothing is sent until you send', self.page.locator('#hopeFeatures').text_content())
self.assertFalse(self.page.evaluate('document.getElementById("libraryDialog").open'))
self.assertEqual(self.page.evaluate('document.activeElement.id'), 'primaryInput')
self.assertTrue(self.page.locator('#featureComposerStatus').is_visible())
self.assertIn('library.txt', self.page.locator('#featureComposerStatus').inner_text())
def test_rejected_files_report_in_current_surface(self):
self.open_panel('Files')
with self.page.expect_file_chooser() as chooser:
self.page.get_by_role('button', name='Upload files', exact=True).click()
chooser.value.set_files({'name':'unsupported.svg','mimeType':'image/svg+xml','buffer':b'bad'})
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("Unsupported")')
self.assertTrue(self.page.evaluate('document.getElementById("libraryDialog").open'))
self.assertIn('unsupported.svg', self.page.locator('.feature-section').filter(has=self.page.get_by_text('Files', exact=True)).locator('.feature-status').inner_text())
self.page.evaluate('document.getElementById("libraryDialog").close()')
self.page.evaluate('HopeFeatures.addFiles([new File([],"empty.txt",{type:"text/plain"})])')
self.assertTrue(self.page.locator('#featureComposerStatus').is_visible())
self.assertIn('empty.txt', self.page.locator('#featureComposerStatus').inner_text())
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
def test_reuse_queues_then_explicit_send_and_focuses(self):
self.open_panel('Files')
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
self.assertFalse(self.page.evaluate('document.getElementById("libraryDialog").open'))
self.assertEqual(self.page.evaluate('document.activeElement.id'), 'primaryInput')
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="POST").length'), 0)
self.assertIn('notes.txt queued', self.page.locator('#featureStatus').text_content())
self.page.get_by_role('button', name='Send', exact=True).click()
self.assertEqual(self.page.evaluate('calls.find(c=>c.path.includes("/messages")).body.attachment_ids'), ['c'*32])
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
def test_reuse_checks_blocked_images_capacity_and_stale_metadata(self):
self.page.evaluate('file.extraction_status="encrypted"')
self.open_panel('Files')
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("could not be extracted")')
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
self.page.evaluate('file.extraction_status="image";file.mime="image/png";capabilities.image_input=false;HopeFeatures.refresh()')
self.page.get_by_role('button', name='Refresh files').click()
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("Image input")')
self.page.evaluate('file.extraction_status="ready";file.mime="text/plain";capabilities.image_input=true;capabilities.attachments_per_message=1;HopeFeatures.refresh()')
self.page.get_by_role('button', name='Refresh files').click()
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"pending.txt",{type:"text/plain"})]);pendingUpload=true')
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
self.page.evaluate('file.id="d".repeat(32);document.getElementById("libraryDialog").showModal()')
self.page.get_by_role('button', name='Refresh files').click()
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("at most 1")')
self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend();file.extraction_status="ready"')
self.page.get_by_role('button', name='Refresh files').click()
self.page.evaluate('file.extraction_status="failed"')
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("could not be extracted")')
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
def test_upload_error_blocked_and_reuse_account_teardown(self):
self.page.evaluate('uploadError="Account storage is full"')
self.page.locator('input[type=file]').set_input_files({'name':'full.txt','mimeType':'text/plain','buffer':b'Hello'})
self.page.get_by_role('button', name='Retry upload').wait_for()
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
self.page.evaluate('uploadError="";extraction="failed"')
self.page.get_by_role('button', name='Retry upload').click()
self.page.wait_for_function('document.getElementById("attachmentPreviews").textContent.includes("blocked")')
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
self.page.evaluate('changeAccount({id:"owner-b",status:"ready"})')
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
self.open_panel('Files')
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
self.page.evaluate('changeAccount(null)')
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
self.assertEqual(self.page.locator('.feature-editor').filter(has_text='Updated facts').count(), 0)
def test_reuse_inflight_account_change_cannot_queue_previous_account(self):
self.page.evaluate('''() => {const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path.startsWith('/api/attachments/')){window.reuseSignal=options.signal;await new Promise(resolve=>window.resolveReuse=resolve);}return original(path,options);};}''')
self.open_panel('Files')
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('!!window.resolveReuse')
self.page.evaluate('changeAccount({id:"owner-b",status:"ready"});resolveReuse()')
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
self.assertTrue(self.page.evaluate('reuseSignal.aborted'))
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
self.assertNotIn('queued', self.page.locator('#featureStatus').text_content())
def test_overlapping_capability_refresh_ignores_old_vision_response(self):
self.page.evaluate('''() => {const original=HopeAuth.request;window.capabilityResolvers=[];HopeAuth.request=(path,options)=>path==='/api/capabilities'?new Promise(resolve=>capabilityResolvers.push(resolve)):original(path,options);window.oldRefresh=HopeFeatures.refresh();window.newRefresh=HopeFeatures.refresh();}''')
self.page.evaluate('''() => {capabilityResolvers[1]({features:{...capabilities,image_input:false}});}''')
self.page.evaluate('() => newRefresh')
self.assertNotIn('image/png', self.page.locator('input[type=file]').get_attribute('accept'))
self.page.evaluate('''() => {capabilityResolvers[0]({features:{...capabilities,image_input:true}});}''')
self.page.evaluate('() => oldRefresh')
self.assertNotIn('image/png', self.page.locator('input[type=file]').get_attribute('accept'))
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"image.png",{type:"image/png"})])')
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 0)
self.assertIn('Image input is not available', self.page.locator('#featureComposerStatus').inner_text())
def test_delayed_reuse_discarded_on_thread_change(self):
self.page.evaluate("window.dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'one'}}))")
self.page.evaluate("""() => {const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path.startsWith('/api/attachments/')) await new Promise(resolve=>window.resolveReuse=resolve); return original(path,options);};}""")
self.open_panel('Files')
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
self.page.wait_for_function('!!window.resolveReuse')
self.page.evaluate("window.dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'two'}}));resolveReuse()")
self.page.wait_for_function('document.querySelector(".feature-row-actions button").disabled===false')
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
def test_real_memory_conflict_requires_explicit_reviewed_revision(self):
self.page.evaluate("""() => {window.latestMemory={section:'memory',content:'Saved facts',revision:'m1'};const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path==='/api/memory')return {sections:[{...latestMemory}]};if(path==='/api/memory/memory'){const data=JSON.parse(options.body);calls.push({path,body:data,method:'PUT'});if(data.revision!==latestMemory.revision)throw Object.assign(new Error('Memory revision conflict. Compare latest saved memory before retrying.'),{status:409});latestMemory={...latestMemory,content:data.content,revision:'m3'};return latestMemory;}return original(path,options);};}""")
self.open_panel('Memory')
content = self.page.get_by_label('Assistant memory', exact=True)
content.fill('My retained local draft')
self.page.evaluate('latestMemory={section:"memory",content:"Concurrent assistant edit",revision:"m2"}')
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
self.page.get_by_role('button', name='Compare latest saved memory', exact=True).click()
self.page.get_by_text('Concurrent assistant edit', exact=True).wait_for()
self.assertEqual(content.input_value(), 'My retained local draft')
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
self.assertEqual(self.page.evaluate('latestMemory.content'), 'Concurrent assistant edit')
self.page.get_by_role('button', name='Use reviewed revision for next save', exact=True).click()
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
self.page.wait_for_function('latestMemory.content==="My retained local draft"')
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/memory/memory").at(-1).body.revision'), 'm2')
def test_reusable_structures_and_task_hints_explicit_paused(self):
self.open_panel('Scheduled tasks')
self.page.get_by_role('button', name='New scheduled task').click()
self.assertTrue(self.page.get_by_label('Create paused', exact=True).is_checked())
self.assertTrue(self.page.get_by_label('Create paused', exact=True).is_visible())
self.assertEqual(self.page.locator('.feature-form .feature-field').count(), 5)
self.assertIn('timezone and next run are not inferred', self.page.locator('.feature-form').inner_text())
self.page.evaluate('const target=document.createElement("div");target.id="movedMemory";document.body.append(target);HopeFeatures.mountMemory(target);document.getElementById("libraryDialog").close();HopeFeatures.openSection("memory")')
self.page.get_by_label('About you', exact=True).wait_for()
self.assertEqual(self.page.locator('#movedMemory .feature-section').count(), 1)
self.assertIn('For example', self.page.locator('#movedMemory').inner_text())
def test_upload_raw_bytes_and_ack_only_clear(self): def test_upload_raw_bytes_and_ack_only_clear(self):
self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'}) self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1') self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
@@ -82,7 +231,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
def test_retained_upload_management_delete_is_explicit(self): def test_retained_upload_management_delete_is_explicit(self):
self.open_panel('Files') self.open_panel('Files')
self.page.get_by_text('Delete notes.txt',exact=True).click() self.page.locator('summary[aria-label="Delete notes.txt"]').click()
self.assertIn("old chat attachment references won't work", self.page.locator('#hopeFeatures').inner_text()) self.assertIn("old chat attachment references won't work", self.page.locator('#hopeFeatures').inner_text())
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="DELETE").length'), 0) self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="DELETE").length'), 0)
self.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content')) self.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content'))
@@ -116,7 +265,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
self.assertTrue(self.page.locator('#hopeFeatures').is_hidden()) self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
def test_artifact_safe_preview_versions_conflict_and_export(self): def test_artifact_safe_preview_versions_conflict_and_export(self):
self.open_panel('Artifacts') self.open_panel('Saved drafts')
self.page.get_by_role('button',name='Edit Draft',exact=False).click() self.page.get_by_role('button',name='Edit Draft',exact=False).click()
self.page.get_by_label('Content',exact=True).fill('<img src=x onerror=alert(1)> changed') self.page.get_by_label('Content',exact=True).fill('<img src=x onerror=alert(1)> changed')
self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> changed') self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> changed')
+3 -1
View File
@@ -74,4 +74,6 @@ def test_ci_qualification_gates_images_and_deployment_without_live_conftest():
assert 'tests/unit/'+name in workflow assert 'tests/unit/'+name in workflow
assert 'pytest-asyncio -r control/requirements.txt' in workflow assert 'pytest-asyncio -r control/requirements.txt' in workflow
assert 'scripts/generate_hope_themes.py --check' in workflow assert 'scripts/generate_hope_themes.py --check' in workflow
assert '-v "$PWD:/src:ro"' in workflow assert 'git archive HEAD | docker run --rm -i' in workflow
assert 'tar -xf - -C /tmp/src' in workflow
assert '-v "$PWD:/src' not in workflow
+71
View File
@@ -1,6 +1,8 @@
"""Offline cancellation/recovery regressions; no HTTP, NAS, secrets or model calls.""" """Offline cancellation/recovery regressions; no HTTP, NAS, secrets or model calls."""
import asyncio import asyncio
import functools import functools
import sqlite3
import threading
import pytest import pytest
@@ -66,6 +68,75 @@ def execution(tmp_path):
accounts.close() accounts.close()
@offline_async
async def test_cancelled_sqlite_worker_is_joined_by_store_close(execution, monkeypatch, tmp_path):
"""Task cancellation is not thread completion: close must serialize with SQL."""
accounts, store, owner, _, _, rid = execution
entered = threading.Event()
release = threading.Event()
close_attempted = threading.Event()
close_entered = threading.Event()
original_lock, original_db = accounts.lock, accounts.db
original_row = store._row
closing_thread = threading.local()
class ObservedLock:
def __enter__(self):
if getattr(closing_thread, 'closing', False):
close_attempted.set()
original_lock.acquire()
return self
def __exit__(self, *args):
original_lock.release()
class ObservedConnection:
def close(self):
close_entered.set()
close_attempted.set()
# Fail safely on the old implementation, without racing native SQL
# close in the test process and potentially segfaulting pytest.
assert original_lock._is_owned(), 'SQLite close must hold the shared store lock'
original_db.close()
def blocked_row(*args):
entered.set()
assert release.wait(5), 'Offline worker release timed out'
return original_row(*args)
def close():
closing_thread.closing = True
accounts.close()
accounts.lock = store.lock = ObservedLock()
monkeypatch.setattr(accounts, 'db', ObservedConnection())
monkeypatch.setattr(store, '_row', blocked_row)
worker = asyncio.create_task(asyncio.to_thread(store.append_event, owner, rid, 'done', {'offline': True}))
closer = None
try:
assert await asyncio.to_thread(entered.wait, 2)
worker.cancel()
with pytest.raises(asyncio.CancelledError):
await worker
closer = asyncio.create_task(asyncio.to_thread(close))
assert await asyncio.to_thread(close_attempted.wait, 2)
assert not close_entered.is_set(), 'Close must wait for the cancelled task\'s SQLite worker'
finally:
release.set()
try:
if closer is not None:
await closer
finally:
monkeypatch.setattr(accounts, 'db', original_db)
accounts.lock = store.lock = original_lock
assert close_entered.is_set()
# The worker's terminal replay event committed before close; no test bypass
# or change to the durable run/event contract is needed.
with sqlite3.connect(str(tmp_path / 'offline.sqlite')) as reopened:
assert reopened.execute('SELECT event, data FROM chat_events WHERE run_id=?', (rid,)).fetchall() == [('done', '{"offline": true}')]
accounts.close() # Teardown close remains idempotent.
def coordinator(execution, adapter, *, upstream=None): def coordinator(execution, adapter, *, upstream=None):
accounts, store, owner, token, cid, rid = execution accounts, store, owner, token, cid, rid = execution
runs = ChatRuns(accounts, store, adapter=adapter) runs = ChatRuns(accounts, store, adapter=adapter)
+3 -1
View File
@@ -244,7 +244,9 @@ def test_realtime_voice_is_inline_in_same_thread_and_creates_real_conversation()
expect(page.locator('#legacyVoice')).to_be_visible() expect(page.locator('#legacyVoice')).to_be_visible()
expect(page.locator('#primaryChat')).to_be_visible() expect(page.locator('#primaryChat')).to_be_visible()
expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk') expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk')
expect(page.locator('#primaryInput')).to_be_enabled() expect(page.locator('#primaryInput')).to_be_disabled()
expect(page.locator('#primaryComposer')).to_be_hidden()
expect(page.locator('#chatTimeline')).to_be_hidden()
expect(page.locator('#primarySend')).to_be_disabled() expect(page.locator('#primarySend')).to_be_disabled()
assert page.evaluate('window.startedVoice') == 'c2' assert page.evaluate('window.startedVoice') == 'c2'
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls) assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
+13 -5
View File
@@ -22,6 +22,8 @@ def test_model_settings_load_save_reload_and_failure():
url = req.request.url url = req.request.url
if '/api/auth/me' in url: if '/api/auth/me' in url:
req.fulfill(json={'user':account}) req.fulfill(json={'user':account})
elif '/api/capabilities' in url:
req.fulfill(json={'version': 1, 'features': {}})
elif '/api/settings/model' in url: elif '/api/settings/model' in url:
calls.append(req.request.method) calls.append(req.request.method)
if req.request.method == 'PUT': if req.request.method == 'PUT':
@@ -41,7 +43,9 @@ def test_model_settings_load_save_reload_and_failure():
page.goto('https://hope.test/') page.goto('https://hope.test/')
page.wait_for_load_state('networkidle') page.wait_for_load_state('networkidle')
assert calls == [] assert calls == []
page.locator('#settingsBtn').click() page.locator('#drawerOpen').click()
page.locator('#accountMenuBtn').click()
page.locator('[data-settings-target="preferences"]').click()
expect(page.locator('#modelSelect')).to_be_enabled() expect(page.locator('#modelSelect')).to_be_enabled()
expect(page.locator('#saveModelSettings')).to_be_disabled() expect(page.locator('#saveModelSettings')).to_be_disabled()
page.locator('#modelSelect').select_option('openai/gpt-6.1-sol-subscription') page.locator('#modelSelect').select_option('openai/gpt-6.1-sol-subscription')
@@ -50,15 +54,19 @@ def test_model_settings_load_save_reload_and_failure():
expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved') expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved')
assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'} assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'}
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.locator('#closeSheetBtn').click() page.locator('#settingsClose').click()
page.reload(); page.wait_for_load_state('networkidle') page.reload(); page.wait_for_load_state('networkidle')
page.locator('#settingsBtn').click() page.locator('#drawerOpen').click()
page.locator('#accountMenuBtn').click()
page.locator('[data-settings-target="preferences"]').click()
expect(page.locator('#modelSelect')).to_have_value(saved['model']) expect(page.locator('#modelSelect')).to_have_value(saved['model'])
expect(page.locator('#thinkingLevelSelect')).to_have_value('high') expect(page.locator('#thinkingLevelSelect')).to_have_value('high')
page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True) page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True)
page.locator('#closeSheetBtn').click() page.locator('#settingsClose').click()
available = False available = False
page.locator('#settingsBtn').click() page.locator('#drawerOpen').click()
page.locator('#accountMenuBtn').click()
page.locator('[data-settings-target="preferences"]').click()
expect(page.locator('#modelSelect')).to_be_disabled() expect(page.locator('#modelSelect')).to_be_disabled()
expect(page.locator('#saveModelSettings')).to_be_disabled() expect(page.locator('#saveModelSettings')).to_be_disabled()
expect(page.locator('#retryModelSettings')).to_be_visible() expect(page.locator('#retryModelSettings')).to_be_visible()
+47 -2
View File
@@ -18,7 +18,7 @@ def settings_page():
'users': [dict(id='bob', username='bob', display_name='Bob', role='user', status='error', container_name='hope-hermes-bob', error='Provisioning failed')], 'users': [dict(id='bob', username='bob', display_name='Bob', role='user', status='error', container_name='hope-hermes-bob', error='Provisioning failed')],
'preferences': dict(palette='opal', appearance='light', revision=1), 'preferences': dict(palette='opal', appearance='light', revision=1),
'model': dict(model='auto', thinking_level='none'), 'model': dict(model='auto', thinking_level='none'),
'memory': True, 'appearance_error': False, 'calls': [], 'errors': [], 'memory': True, 'appearance_error': False, 'capability_error': False, 'calls': [], 'errors': [],
} }
page.on('pageerror', lambda error: state['errors'].append(str(error))) page.on('pageerror', lambda error: state['errors'].append(str(error)))
@@ -32,7 +32,10 @@ def settings_page():
request.fulfill(status=401, json={'error': 'Sign in required'}); return request.fulfill(status=401, json={'error': 'Sign in required'}); return
data = {'user': state['user']} data = {'user': state['user']}
elif path == '/api/auth/logout': data = {'ok': True} elif path == '/api/auth/logout': data = {'ok': True}
elif path == '/api/capabilities': data = {'features': {'memory_controls': state['memory']}} elif path == '/api/capabilities':
if state['capability_error']:
request.fulfill(status=503, json={'error': 'File support unavailable'}); return
data = {'features': {'memory_controls': state['memory']}}
elif path == '/api/research': data = {'available': False} elif path == '/api/research': data = {'available': False}
elif path == '/api/preferences': elif path == '/api/preferences':
if state['appearance_error']: if state['appearance_error']:
@@ -185,6 +188,48 @@ def test_appearance_revision_save_failure_and_memory_real_controls(settings_page
assert page.locator('[data-settings-panel="privacy"] #featureStatus').count() == 0 assert page.locator('[data-settings-panel="privacy"] #featureStatus').count() == 0
def test_mobile_activity_closes_drawer_and_task_fields_stack(settings_page):
page, state = settings_page
page.locator('#drawerOpen').click()
page.locator('#tasksBtn').click()
expect(page.locator('#chatSidebar')).not_to_have_class('chat-sidebar drawer-open')
expect(page.locator('#drawerBackdrop')).to_be_hidden()
expect(page.locator('#tasksPanel')).to_be_visible()
expect(page.locator('#closeTasksBtn')).to_be_focused()
page.locator('#closeTasksBtn').click()
expect(page.locator('#drawerOpen')).to_be_focused()
def test_model_save_file_support_failure_has_working_retry(settings_page):
page, state = settings_page
open_section(page, 'preferences')
state['capability_error'] = True
page.locator('#thinkingLevelSelect').select_option('high')
page.locator('#saveModelSettings').click()
expect(page.locator('#modelSettingsStatus')).to_contain_text('file support could not be checked')
expect(page.locator('#retryModelSettings')).to_be_visible()
before = len([call for call in state['calls'] if call[1] == '/api/capabilities'])
state['capability_error'] = False
page.locator('#retryModelSettings').click()
expect(page.locator('#modelSettingsStatus')).to_contain_text('saved settings are loaded')
expect(page.locator('#retryModelSettings')).to_be_hidden()
page.wait_for_function("document.getElementById('featureAvailability').textContent.includes('Files are unavailable.')")
assert len([call for call in state['calls'] if call[1] == '/api/capabilities']) > before
def test_security_explains_real_connection_and_signs_out(settings_page):
page, state = settings_page
open_section(page, 'security')
expect(page.locator('#securityServer')).to_have_text('hope.test')
expect(page.locator('#securityConnection')).to_contain_text('HTTPS')
expect(page.locator('#securityIdentity')).to_contain_text('@alice')
assert page.locator('[data-settings-panel="security"] input').count() == 0
page.get_by_role('button', name='Sign out of this browser', exact=True).click()
expect(page.locator('#settingsDialog')).not_to_be_visible()
expect(page.locator('#loginGate')).to_be_visible()
assert any(method == 'POST' and path == '/api/auth/logout' for method, path, _ in state['calls'])
def test_preferences_preserve_active_voice_and_model_owner(settings_page): def test_preferences_preserve_active_voice_and_model_owner(settings_page):
page, state = settings_page page, state = settings_page
page.evaluate("window.voiceEvents=[];window.addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))") page.evaluate("window.voiceEvents=[];window.addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
+221
View File
@@ -0,0 +1,221 @@
"""Offline sidebar mutations using real assets and mocked authenticated APIs."""
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, expect
from test_chat_web import fixture
def setup(page):
calls, streams, conversations, messages = fixture(page)
conversations['c2'] = dict(conversations['c1'], id='c2', title='Another thought')
messages['c2'] = []
page.goto('https://hope.test/')
page.wait_for_load_state('networkidle')
if page.locator('#drawerOpen').is_visible():
page.locator('#drawerOpen').click()
page.get_by_role('button', name='A saved thought', exact=True).click()
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
page.locator('#primaryInput').fill('Keep my selected draft')
return calls, conversations
def menu(page, title='Another thought'):
page.get_by_role('button', name='Actions for ' + title, exact=True).click()
return page.locator('.conversation-row-menu')
def test_nonselected_pin_rename_archive_unarchive_preserve_selected_chat():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':1280, 'height':900})
calls, conversations = setup(page)
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
menu(page).get_by_role('button', name='Unpin', exact=True).click()
expect(page.locator('#recentConversations')).to_contain_text('Another thought')
menu(page).get_by_role('button', name='Rename', exact=True).click()
page.locator('#chatActionValue').fill('Renamed thought')
page.locator('#chatActionSave').click()
expect(page.locator('#chatActionDialog')).not_to_be_visible()
menu(page, 'Renamed thought').get_by_role('button', name='Archive', exact=True).click()
page.locator('#chatActionSave').click()
expect(page.locator('#recentConversations')).not_to_contain_text('Renamed thought')
page.locator('#showArchived').click()
menu(page, 'Renamed thought').get_by_role('button', name='Unarchive', exact=True).click()
page.locator('#chatActionSave').click()
expect(page.locator('#recentConversations')).not_to_contain_text('Renamed thought')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
patches = [body for method, path, body in calls if method == 'PATCH']
assert [body['revision'] for body in patches] == [1, 2, 3, 4, 5]
assert not any(method == 'GET' and path == '/api/conversations/c2' for method, path, body in calls)
assert conversations['c2']['archived'] is False
browser.close()
def test_nonselected_conflict_refresh_retry_and_failed_refresh_errors():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
def conflict(route):
if route.request.method == 'PATCH':
conversations['c2'].update(title='Newest title', revision=8)
route.fulfill(status=409, json={'error':'Stale revision'})
else:
route.fallback()
page.route('https://hope.test/api/conversations/c2', conflict)
menu(page).get_by_role('button', name='Rename', exact=True).click()
page.locator('#chatActionValue').fill('My title')
page.locator('#chatActionSave').click()
expect(page.locator('#chatActionError')).to_contain_text('Stale revision')
expect(page.locator('#chatActionError')).to_contain_text('Conversation refreshed')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
page.unroute('https://hope.test/api/conversations/c2', conflict)
page.locator('#chatActionSave').click()
expect(page.locator('#recentConversations')).to_contain_text('My title')
assert next(body for method, path, body in reversed(calls) if method == 'PATCH')['revision'] == 8
def unavailable(route):
route.fulfill(status=503, json={'error':'Service unavailable'})
page.route('https://hope.test/api/conversations/c2', unavailable)
menu(page, 'My title').get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#chatNotice')).to_contain_text('Service unavailable')
expect(page.locator('#chatNotice')).to_contain_text('Could not refresh')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
browser.close()
def test_row_menu_keyboard_top_layer_and_selected_voice_safety():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':390, 'height':844})
calls, conversations = setup(page)
page.locator('#drawerOpen').click()
trigger = page.get_by_role('button', name='Actions for Another thought', exact=True)
trigger.focus(); page.keyboard.press('Enter')
expect(page.locator('.conversation-row-menu')).to_be_visible()
expect(page.locator('.conversation-row-menu button').first).to_be_focused()
assert page.locator('.conversation-row-menu').evaluate("el => el.matches(':popover-open')")
assert page.locator('.conversation-row-menu').evaluate('el => {const r=el.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight;}')
page.keyboard.press('Escape')
expect(page.locator('.conversation-row-menu')).not_to_be_visible()
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded', 'false')
page.locator('#drawerOpen').click()
active = menu(page, 'A saved thought')
for name in ['Pin', 'Rename', 'Archive']:
expect(active.get_by_role('button', name=name, exact=True)).to_be_disabled()
page.keyboard.press('Escape')
if page.locator('#drawerOpen').get_attribute('aria-expanded') == 'false':
page.locator('#drawerOpen').click()
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
browser.close()
def test_nonselected_pin_preserves_selected_running_stream():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
streams = []
page.route('https://hope.test/api/runs/r1/events?**', lambda route: streams.append(route))
with page.expect_request(lambda request: '/api/runs/r1/events?' in request.url):
page.locator('#primarySend').click()
expect(page.locator('#cancelRun')).to_be_visible()
expect(page.locator('#primaryInput')).to_be_disabled()
active = menu(page, 'A saved thought')
expect(active.get_by_role('button', name='Pin', exact=True)).to_be_disabled()
page.keyboard.press('Escape')
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
expect(page.locator('#cancelRun')).to_be_visible()
expect(page.locator('#primaryInput')).to_be_disabled()
assert page.evaluate('HopeChat.running') is True
assert streams
streams[0].fulfill(content_type='text/event-stream', body='id: 1\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n')
expect(page.locator('#primaryInput')).to_be_enabled()
browser.close()
def test_late_sidebar_patch_does_not_cross_account_epoch():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
pending = []
def delayed(route):
if route.request.method == 'PATCH': pending.append(route)
else: route.fallback()
page.route('https://hope.test/api/conversations/c2', delayed)
with page.expect_request(lambda request: request.method == 'PATCH'):
menu(page).get_by_role('button', name='Pin', exact=True).click()
assert pending
page.evaluate('HopeAuth.signOut()')
pending[0].fulfill(status=409, json={'error':'Old account failure'})
expect(page.locator('#loginGate')).to_be_visible()
expect(page.locator('#chatNotice')).to_have_text('')
expect(page.locator('#recentConversations')).to_be_empty()
assert not any(method == 'GET' and path == '/api/conversations/c2' for method, path, body in calls)
browser.close()
def test_delayed_selected_archive_does_not_invalidate_pending_navigation():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
patches, navigations = [], []
def delayed_patch(route):
if route.request.method == 'PATCH': patches.append(route)
else: route.fallback()
page.route('https://hope.test/api/conversations/c1', delayed_patch)
page.route('https://hope.test/api/conversations/c2', lambda route: navigations.append(route))
menu(page, 'A saved thought').get_by_role('button', name='Archive', exact=True).click()
with page.expect_request(lambda request: request.method == 'PATCH'):
page.locator('#chatActionSave').click()
# Dismiss the pending confirmation, then navigate while its write is still pending.
page.locator('#chatActionCancel').click()
with page.expect_request(lambda request: urlparse(request.url).path == '/api/conversations/c2'):
page.get_by_role('button', name='Another thought', exact=True).click()
expect(page.locator('#chatNotice')).to_have_text('Loading conversation…')
conversations['c1'].update(archived=True, revision=2)
patches[0].fulfill(json={'conversation':conversations['c1']})
expect(page.locator('#recentConversations')).not_to_contain_text('A saved thought')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
navigations[0].fulfill(json={'conversation':conversations['c2'], 'messages':[], 'runs':[], 'next_cursor':None})
expect(page.locator('#chatTitle')).to_have_text('Another thought')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
browser.close()
def test_mutation_restarts_pagination_in_current_search_workspace_scope():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
queries = []
conversations['c2']['workspace_id'] = 'w1'
third = dict(conversations['c2'], id='c3', title='Another page')
def pages(route):
query = parse_qs(urlparse(route.request.url).query)
queries.append(query)
route.fulfill(json={'conversations':[third] if 'cursor' in query else [conversations['c2']], 'next_cursor':None if 'cursor' in query else 'page2'})
page.route('https://hope.test/api/conversations?**', pages)
page.get_by_role('button', name='Writing', exact=True).click()
with page.expect_response(lambda response: '/api/conversations?' in response.url and 'q=Another' in response.url):
page.locator('#conversationSearch').fill('Another')
expect(page.locator('#loadMoreConversations')).to_be_visible()
page.locator('#loadMoreConversations').click()
expect(page.locator('#recentConversations')).to_contain_text('Another page')
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
expect(page.locator('#recentConversations')).not_to_contain_text('Another page')
assert queries[-1]['q'] == ['Another']
assert queries[-1]['workspace_id'] == ['w1']
assert queries[-1]['archived'] == ['false']
assert 'cursor' not in queries[-1]
expect(page.locator('#loadMoreConversations')).to_be_visible()
browser.close()
+132
View File
@@ -0,0 +1,132 @@
"""Offline dedicated voice shell tests using the real start/stop code and fake SDK."""
from playwright.sync_api import sync_playwright, expect
from test_chat_web import fixture
from test_voice_settings_ui import VOICE_SDK
def transport(page, token_failure=False, connecting=False):
page.route('https://hope.test/livekit-client.umd.js', lambda r: r.fulfill(
body=VOICE_SDK.replace("this.state='connected';const agent", "if(window.holdConnect) await new Promise(resolve=>window.releaseConnect=resolve);this.state='connected';const agent"),
content_type='application/javascript'))
def token(r):
page.evaluate('(body) => window.lastTokenBody=body', r.request.post_data_json)
if token_failure:
r.fulfill(status=503, json={'error': 'Voice unavailable'})
else:
r.fulfill(json={'token': 'offline-token', 'conversation_id': r.request.post_data_json.get('conversation_id')})
page.route('https://hope.test/token', token)
if connecting:
page.add_init_script('window.holdConnect=true')
def open_thread(page, width):
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
if width <= 760:
page.locator('#drawerOpen').click()
page.get_by_role('button', name='A saved thought', exact=True).click()
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
def packet(page, text):
page.evaluate("""text => testRoom.emit('DataReceived',new TextEncoder().encode(JSON.stringify({type:'transcript',role:'agent',text})),testRoom.remoteParticipants.get('agent'),null,'transcript')""", text)
def test_voice_shell_real_start_mute_scroll_stop_and_saved_history():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
for width, height in [(1440, 900), (390, 844), (320, 568), (844, 390)]:
page = browser.new_page(viewport={'width': width, 'height': height}, reduced_motion='reduce')
errors = []; page.on('pageerror', lambda e: errors.append(str(e)))
calls, _, conversations, messages = fixture(page); transport(page)
page.add_init_script("localStorage.setItem('hope_history:https://hope.test:alice',JSON.stringify([{role:'agent',text:'Unrelated account snapshot'}]))")
open_thread(page, width)
page.locator('#primaryInput').fill('Keep my draft')
page.locator('#talkToHope').click()
page.wait_for_function('HopeVoice.state().ready')
expect(page.locator('#legacyVoice')).to_be_visible()
for selector in ['#primaryComposer', '#chatTimeline', '.chat-footnote']:
expect(page.locator(selector)).to_be_hidden()
expect(page.locator('#primaryInput')).to_be_disabled()
expect(page.locator('#voiceHistory')).to_contain_text('Original question')
assert 'Unrelated account snapshot' not in page.locator('#conversation').inner_text()
page.locator('#muteBtn').click()
expect(page.locator('#muteBtn')).to_have_text('Unmute')
expect(page.locator('#muteBtn')).to_have_attribute('aria-pressed', 'true')
page.locator('#muteBtn').click()
for n in range(12): packet(page, f'Live response {n} ' + 'Readable transcript. ' * 30)
packet(page, '<img src=x onerror=alert(1)>')
assert page.locator('#messages img').count() == 0
assert page.locator('#voiceHistory').get_by_text('Original question', exact=True).count() == 1
assert page.evaluate("""() => {const s=document.querySelector('#conversation');return s.scrollHeight>s.clientHeight && s.scrollTop>0;}""")
page.locator('#conversation').evaluate('(el) => el.scrollTop=0')
packet(page, 'Do not jump while reading earlier messages')
assert page.locator('#conversation').evaluate('(el) => el.scrollTop') == 0
for control in ['#muteBtn', '#stopBtn', '#settingsBtn']:
box = page.locator(control).bounding_box()
assert box and box['y'] >= 0 and box['y'] + box['height'] <= height
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.locator('#settingsBtn').click(); expect(page.locator('#settingsDialog')).to_be_visible()
page.locator('#settingsClose').click()
messages['c1'].append(dict(id='v1', role='assistant', content='Actually saved voice reply', status='completed'))
page.locator('#stopBtn').click()
expect(page.locator('#legacyVoice')).to_be_hidden()
expect(page.locator('#chatTimeline')).to_contain_text('Actually saved voice reply')
expect(page.locator('#primaryInput')).to_have_value('Keep my draft')
expect(page.locator('#primaryInput')).to_be_enabled()
expect(page.locator('#talkToHope')).to_be_focused()
assert page.evaluate('window.lastTokenBody') == {'conversation_id': 'c1'}
assert not errors
page.close()
browser.close()
def test_compatibility_text_composer_uses_http_and_preserves_primary_draft():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(); calls, _, _, _ = fixture(page); transport(page)
open_thread(page, 1280)
page.locator('#primaryInput').fill('Existing durable draft')
page.evaluate("document.getElementById('chatInput').value='Legacy text';document.getElementById('chatComposer').requestSubmit()")
expect(page.locator('#primaryInput')).to_have_value('Existing durable draft')
assert not any(path.endswith('/messages') or path == '/token' for _, path, _ in calls)
page.locator('#primaryInput').fill('')
page.evaluate("document.getElementById('chatComposer').requestSubmit()")
expect(page.locator('#chatTimeline')).to_contain_text('Legacy text')
assert any(path.endswith('/messages') and body['content'] == 'Legacy text' for _, path, body in calls)
assert page.evaluate('window.lastTokenBody') is None
assert page.evaluate('window.testRoom') is None
browser.close()
def test_voice_failure_cancel_disconnect_and_logout_restore_text():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
for mode in ['token-failure', 'mic-failure', 'cancel', 'disconnect', 'logout']:
page = browser.new_page(viewport={'width': 390, 'height': 844})
fixture(page); transport(page, token_failure=mode == 'token-failure', connecting=mode == 'cancel')
if mode == 'mic-failure': page.add_init_script('window.denyMic=true')
open_thread(page, 390); page.locator('#primaryInput').fill('Draft survives')
page.locator('#talkToHope').click()
if mode == 'cancel':
expect(page.locator('#stopBtn')).to_be_visible()
page.locator('#stopBtn').click()
page.evaluate('window.releaseConnect?.()')
elif mode not in ['token-failure', 'mic-failure']:
page.wait_for_function('HopeVoice.state().ready')
if mode == 'disconnect': page.evaluate("testRoom.emit('Disconnected')")
else:
page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click(); page.locator('#logoutBtn').click()
expect(page.locator('#legacyVoice')).to_be_hidden()
page.wait_for_function('!HopeVoice.state().active && !HopeVoice.state().microphone_enabled')
if mode == 'logout':
expect(page.locator('#loginGate')).to_be_visible()
expect(page.locator('#primaryInput')).to_have_value('')
expect(page.locator('#voiceHistory')).to_be_empty()
else:
expect(page.locator('#primaryComposer')).to_be_visible()
expect(page.locator('#primaryInput')).to_be_enabled()
expect(page.locator('#primaryInput')).to_have_value('Draft survives')
if mode.endswith('failure'): expect(page.locator('#chatNotice')).to_contain_text('Could not start voice')
else: expect(page.locator('#chatNotice')).not_to_contain_text('saved')
page.close()
browser.close()
+57 -31
View File
@@ -34,6 +34,7 @@ let tokenAbort = null;
let voiceConversationId = null; let voiceConversationId = null;
let voiceSessionActive = false; let voiceSessionActive = false;
let lastVoiceState = ""; let lastVoiceState = "";
let voiceError = null;
function escapeHtml(s) { function escapeHtml(s) {
const d = document.createElement("div"); const d = document.createElement("div");
@@ -95,7 +96,13 @@ function setTasksOpen(open) {
tasksPanel.hidden = !open; tasksPanel.inert = !open; tasksPanel.hidden = !open; tasksPanel.inert = !open;
tasksPanel.classList.toggle("open", open); tasksPanel.classList.toggle("open", open);
tasksBtn.setAttribute("aria-expanded", String(open)); tasksBtn.setAttribute("aria-expanded", String(open));
if (open) closeTasksBtn.focus(); else if (wasOpen) tasksBtn.focus(); if (open) {
if (document.getElementById('chatSidebar')?.classList.contains('drawer-open')) document.getElementById('drawerClose')?.click();
closeTasksBtn.focus();
} else if (wasOpen) {
const target = tasksBtn.getClientRects().length && !tasksBtn.closest('[inert]') ? tasksBtn : document.getElementById('drawerOpen');
target?.focus();
}
} }
tasksBtn.setAttribute("aria-expanded", "false"); tasksBtn.setAttribute("aria-expanded", "false");
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open"))); tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
@@ -134,7 +141,7 @@ async function setConversationMode(mode) {
if (mode === "voice" && previousMode === "text") { if (mode === "voice" && previousMode === "text") {
chatInput.blur(); chatInput.blur();
window.setTimeout(() => { window.setTimeout(() => {
document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: "smooth" }); document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
}, 0); }, 0);
} }
@@ -464,7 +471,15 @@ function formatTimestamp(ts) {
return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
} }
function renderMessage(msg) { function transcriptFollows() {
const transcript = document.getElementById("conversation");
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 100;
}
function scrollTranscript(follow) {
document.getElementById("voiceTranscriptHint").hidden = !!messagesEl.children.length;
if (follow) {const transcript = document.getElementById("conversation"); transcript.scrollTop = transcript.scrollHeight;}
}
function renderMessage(msg, follow = transcriptFollows()) {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = `message-row ${msg.role}`; wrap.className = `message-row ${msg.role}`;
wrap.dataset.ts = formatTimestamp(msg.ts); wrap.dataset.ts = formatTimestamp(msg.ts);
@@ -486,26 +501,28 @@ function renderMessage(msg) {
} }
wrap.appendChild(bubble); wrap.appendChild(bubble);
messagesEl.appendChild(wrap); messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight; scrollTranscript(follow);
} }
function addMessage(role, text) { function addMessage(role, text) {
const follow = transcriptFollows();
clearPartials(); clearPartials();
const msg = { role, text, ts: Date.now() }; const msg = { role, text, ts: Date.now() };
history.push(msg); history.push(msg);
persistHistory(); persistHistory();
renderMessage(msg); renderMessage(msg, follow);
return msg; return msg;
} }
// Partial transcripts render greyed and update in place until a final arrives. // Partial transcripts render greyed and update in place until a final arrives.
function addPartial(role, text) { function addPartial(role, text) {
const follow = transcriptFollows();
const rows = messagesEl.querySelectorAll(".message-row.partial"); const rows = messagesEl.querySelectorAll(".message-row.partial");
let last = null; let last = null;
for (const r of rows) last = r; for (const r of rows) last = r;
if (last && last.dataset.role === role) { if (last && last.dataset.role === role) {
last.querySelector(".bubble").textContent = cleanSpokenText(text); last.querySelector(".bubble").textContent = cleanSpokenText(text);
messagesEl.scrollTop = messagesEl.scrollHeight; scrollTranscript(follow);
return; return;
} }
const wrap = document.createElement("div"); const wrap = document.createElement("div");
@@ -521,7 +538,7 @@ function addPartial(role, text) {
bubble.textContent = cleanSpokenText(text); bubble.textContent = cleanSpokenText(text);
wrap.appendChild(bubble); wrap.appendChild(bubble);
messagesEl.appendChild(wrap); messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight; scrollTranscript(follow);
} }
function clearPartials() { function clearPartials() {
@@ -530,8 +547,8 @@ function clearPartials() {
function renderHistory() { function renderHistory() {
messagesEl.innerHTML = ""; messagesEl.innerHTML = "";
for (const msg of history) renderMessage(msg); for (const msg of history) renderMessage(msg, false);
messagesEl.scrollTop = messagesEl.scrollHeight; scrollTranscript(true);
} }
clearBtn.addEventListener("click", () => { clearBtn.addEventListener("click", () => {
@@ -727,7 +744,18 @@ modelSelect.addEventListener("change", () => {
thinkingLevelSelect.addEventListener("change", () => { thinkingLevelSelect.addEventListener("change", () => {
updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account."); updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account.");
}); });
retryModelSettingsBtn.addEventListener("click", loadModelSettings); retryModelSettingsBtn.addEventListener("click", async () => {
await loadModelSettings();
if (!window.HopeAuth?.user) return;
const account = accountIdentity(), generation = settingsGeneration;
try {await window.HopeFeatures?.refresh();}
catch (error) {
if (settingsResponseIsCurrent(account, generation)) {
retryModelSettingsBtn.hidden = false;
setSettingsStatus(`File support could not be checked: ${error.message}. Retry to check again. Image attachments remain blocked.`, 'error');
}
}
});
modelSettingsForm.addEventListener("submit", async event => { modelSettingsForm.addEventListener("submit", async event => {
event.preventDefault(); updateSettingsControls(); event.preventDefault(); updateSettingsControls();
if (saveModelSettingsBtn.disabled) return; if (saveModelSettingsBtn.disabled) return;
@@ -740,6 +768,12 @@ modelSettingsForm.addEventListener("submit", async event => {
const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal }); const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal });
if (!settingsResponseIsCurrent(account, generation)) return; if (!settingsResponseIsCurrent(account, generation)) return;
savedModelSettings = readSavedSettings(data); renderSavedModelSettings(); savedModelSettings = readSavedSettings(data); renderSavedModelSettings();
window.HopeFeatures?.refresh().catch(error => {
if (settingsResponseIsCurrent(account, generation)) {
retryModelSettingsBtn.hidden = false;
setSettingsStatus(`Model saved, but file support could not be checked: ${error.message}. Use Retry to check again. Image attachments remain blocked.`, 'error');
}
});
setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success"); setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success");
} catch (error) { } catch (error) {
if (!settingsResponseIsCurrent(account, generation)) return; if (!settingsResponseIsCurrent(account, generation)) return;
@@ -1039,7 +1073,7 @@ function voiceState() {
const ready = agent_connected && agentReady && microphone_enabled && !micMuted; const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle", return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
ready, muted: micMuted, connected, agent_connected, microphone_enabled, ready, muted: micMuted, connected, agent_connected, microphone_enabled,
conversation_id: voiceConversationId, cid: voiceConversationId }; conversation_id: voiceConversationId, cid: voiceConversationId, error: voiceError };
} }
function publishVoiceState() { function publishVoiceState() {
const detail = voiceState(); const detail = voiceState();
@@ -1089,6 +1123,7 @@ async function connectConversation() {
const attempt = ++connectionEpoch; const attempt = ++connectionEpoch;
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.'); if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
voiceConversationId = window.HopeChat?.conversationId || null; voiceConversationId = window.HopeChat?.conversationId || null;
voiceError = null; history = []; messagesEl.replaceChildren(); scrollTranscript(true);
voiceSessionActive = true; publishVoiceState(); voiceSessionActive = true; publishVoiceState();
startBtn.disabled = true; stopBtn.disabled = false; startBtn.disabled = true; stopBtn.disabled = false;
const promise = (async () => { const promise = (async () => {
@@ -1116,7 +1151,7 @@ async function connectConversation() {
} }
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room; muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
} catch (error) { } catch (error) {
if (attempt === connectionEpoch) { cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; } if (attempt === connectionEpoch) { voiceError = error.message; cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; }
throw error; throw error;
} finally { if (attempt === connectionEpoch) connectionPromise = null; } } finally { if (attempt === connectionEpoch) connectionPromise = null; }
})(); })();
@@ -1158,24 +1193,13 @@ chatComposer.addEventListener("submit", async (event) => {
const text = chatInput.value.trim(); const text = chatInput.value.trim();
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return; if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
sendChatBtn.disabled = true; // Compatibility composer delegates to durable HTTP chat, never voice transport.
chatInput.disabled = true; const input = document.getElementById("primaryInput");
try { if (voiceSessionActive || input.disabled || input.value.trim()) return;
if (conversationMode !== "text") await setConversationMode("text"); input.value = text;
await connectConversation(); input.dispatchEvent(new Event("input", {bubbles:true}));
setState("thinking"); document.getElementById("primaryComposer").requestSubmit();
await room.localParticipant.sendText(text, { topic: "lk.chat" }); chatInput.value = ""; resizeChatInput();
chatInput.value = "";
resizeChatInput();
} catch (err) {
console.error("Text message failed:", err);
statusEl.textContent = `Message not sent: ${err.message}`;
setState(room ? "listening" : "idle");
} finally {
chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready";
sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim();
chatInput.focus();
}
}); });
// ── Stop ──────────────────────────────────────────────────────────────────── // ── Stop ────────────────────────────────────────────────────────────────────
@@ -1202,6 +1226,8 @@ muteBtn.addEventListener("click", toggleMute);
function updateMuteIcon() { function updateMuteIcon() {
publishVoiceState(); publishVoiceState();
const muted = muteBtn.classList.toggle("muted", micMuted); const muted = muteBtn.classList.toggle("muted", micMuted);
muteBtn.textContent = muted ? "Unmute" : "Mute";
muteBtn.setAttribute("aria-pressed", String(muted));
muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone"); muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone"; muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
} }
@@ -1251,7 +1277,7 @@ HopeAuth.onChange((user, changed) => {
voiceSearchEl.value = ""; voiceSearchEl.value = "";
if (user) loadVoiceCatalog(); if (user) loadVoiceCatalog();
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null; historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true; history = []; renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
chatInput.value = ""; sendChatBtn.disabled = true; chatInput.value = ""; sendChatBtn.disabled = true;
setTasksOpen(false); closeSheet(); setTasksOpen(false); closeSheet();
} }
+108 -14
View File
@@ -3,16 +3,19 @@
'use strict'; 'use strict';
const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal}); const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal});
const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null}; const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null};
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false; let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false, accountEpoch = 0, rowMenu;
const sidebarWrites = new Set();
const selectedLocked = id => id === state.conversation?.id && (state.busy || !!state.stream || !terminal(state.run) || state.voiceActive);
function closeRowMenu() {if (rowMenu?.matches(':popover-open')) rowMenu.hidePopover();}
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status); const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
const notice = text => { $('chatNotice').textContent = text; }; const notice = text => { $('chatNotice').textContent = text; };
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; }; const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => {const epoch = accountEpoch; Promise.resolve().then(fn).catch(error => {if (epoch === accountEpoch) notice(error.message);});}); return b; };
function controls() { function controls() {
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive; const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy)); $('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream || state.voiceActive; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim(); $('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy; $('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run); for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run) || state.voiceActive;
$('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation; $('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation;
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive'; $('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin'; $('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
@@ -23,7 +26,51 @@
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}})); window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
controls(); controls();
} }
window.addEventListener('hope:voice-state', event => {const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else {if (wasActive) notice(''); if (wasActive && HopeAuth.user && state.conversation) refreshSelected().then(() => {notice('Voice conversation saved.'); list();}).catch(error => notice(error.message));}}); let voiceReturnFocus = null;
function voiceSurface(active) {
const wasActive = $('primaryChat').dataset.voiceActive === 'true';
if (active && !wasActive) {
voiceReturnFocus = document.activeElement;
const history = $('voiceHistory'); history.replaceChildren();
// Snapshot only this selected durable thread; live turns are separate until refreshed on stop.
for (const message of state.messages) {
const row = document.createElement('article'); row.className = `chat-message ${message.role}`;
const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope';
row.append(name, markdown(message.content)); history.append(row);
}
}
$('primaryChat').dataset.voiceActive = String(active);
$('legacyVoice').hidden = !active;
for (const id of ['chatTimeline', 'primaryComposer', 'runProgress']) {$(id).hidden = active; $(id).inert = active;}
document.querySelector('.chat-footnote').hidden = active;
document.querySelector('.skip-link').href = active ? '#conversation' : '#chatTimeline';
if (active && !wasActive) {
if ($('chatSidebar').classList.contains('drawer-open')) drawer(false);
$('conversationActions').matches(':popover-open') && $('conversationActions').hidePopover();
$('stopBtn').disabled = false; $('stopBtn').focus({preventScroll:true});
$('conversation').scrollTop = $('conversation').scrollHeight;
} else if (!active && wasActive && HopeAuth.user) {
if ($('legacyVoice').contains(document.activeElement) || document.activeElement === document.body) {
const target = voiceReturnFocus?.isConnected && voiceReturnFocus.getClientRects().length && !voiceReturnFocus.closest('[inert]') && !voiceReturnFocus.disabled ? voiceReturnFocus : $('talkToHope');
target.focus({preventScroll:true});
}
voiceReturnFocus = null;
}
}
window.addEventListener('hope:voice-state', event => {
const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active;
voiceSurface(state.voiceActive); controls();
if (state.voiceActive) notice('');
else if (wasActive) {
const epoch = state.epoch, cid = state.conversation?.id, before = new Set(state.messages.map(m => m.id));
notice(event.detail?.error ? 'Could not start voice: ' + event.detail.error : 'Voice ended. Refreshing conversation…');
if (HopeAuth.user && cid) refreshSelected().then(() => {
if (epoch !== state.epoch || cid !== state.conversation?.id || state.voiceActive) return;
if (!event.detail?.error) notice(state.messages.some(m => !before.has(m.id)) ? 'Voice conversation saved. History refreshed.' : 'Voice ended. History refreshed.');
return list();
}).catch(error => {if (epoch === state.epoch && !state.voiceActive) notice('Could not refresh voice history: ' + error.message);});
}
});
window.addEventListener('hope:attachments-changed', controls); window.addEventListener('hope:attachments-changed', controls);
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}}; window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
function stopStream() { state.stream?.abort(); state.stream = null; } function stopStream() { state.stream?.abort(); state.stream = null; }
@@ -34,18 +81,17 @@
(open ? $('drawerClose') : $('drawerOpen')).focus(); (open ? $('drawerClose') : $('drawerOpen')).focus();
} }
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false); $('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} }); document.addEventListener('keydown', event => { if (event.key === 'Escape' && rowMenu?.matches(':popover-open')) {closeRowMenu(); return;} if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
const mobile = matchMedia('(max-width: 760px)'); const mobile = matchMedia('(max-width: 760px)');
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } } function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
mobile.addEventListener('change', adapt); adapt(); mobile.addEventListener('change', adapt); adapt();
async function startVoice() { async function startVoice() {
if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; } if (state.voiceActive) { $('stopBtn').focus(); return; }
if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return; if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
const epoch = state.epoch; state.busy = true; controls(); const epoch = state.epoch; state.busy = true; controls();
try { try {
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: 'Voice conversation', ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation); await list();} if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: 'Voice conversation', ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation); await list();}
if (epoch !== state.epoch) return; if (epoch !== state.epoch) return;
$('legacyVoice').hidden = false;
window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}})); window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}}));
} catch (error) {if (epoch === state.epoch) notice(error.message);} finally {if (epoch === state.epoch) {state.busy = false; controls();}} } catch (error) {if (epoch === state.epoch) notice(error.message);} finally {if (epoch === state.epoch) {state.busy = false; controls();}}
} }
@@ -108,10 +154,58 @@
if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor); if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor);
const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return; const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return;
if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); } if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); }
for (const conversation of data.conversations) {const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false'); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(b);} closeRowMenu();
for (const conversation of data.conversations) {
const row = document.createElement('div'); row.className = 'conversation-row'; row.dataset.conversationId = conversation.id;
const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false');
const actions = button('•••', () => showRowMenu(conversation, actions), 'conversation-row-actions'); actions.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat')); actions.setAttribute('aria-haspopup', 'dialog'); actions.setAttribute('aria-expanded', 'false');
row.append(b, actions); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(row);
}
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor; state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
} }
function showRowMenu(conversation, trigger) {
closeRowMenu(); rowMenu?.remove();
const menu = document.createElement('div'); rowMenu = menu; menu.id = 'conversationRowActions'; trigger.setAttribute('aria-controls', menu.id); menu.className = 'conversation-row-menu'; menu.popover = 'auto'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat'));
const epoch = accountEpoch, user = HopeAuth.user?.id;
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
const pin = button(conversation.pinned ? 'Unpin' : 'Pin', async () => {closeRowMenu(); if (current()) await updateConversation(conversation, {pinned: !conversation.pinned});});
const rename = button('Rename', () => {closeRowMenu(); if (current()) dialog('Rename conversation', conversation.title, title => updateConversation(conversation, {title}));});
const archive = button(conversation.archived ? 'Unarchive' : 'Archive', () => {closeRowMenu(); if (current()) dialog(conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => updateConversation(conversation, {archived: !conversation.archived}), 'The conversation is retained. Only its archive status changes.', false);});
for (const b of [pin, rename, archive]) b.disabled = selectedLocked(conversation.id) || sidebarWrites.has(conversation.id);
menu.append(pin, rename, archive); $('chatSidebar').append(menu);
menu.addEventListener('toggle', () => {const expanded = menu.matches(':popover-open'); trigger.setAttribute('aria-expanded', String(expanded)); if (!expanded && trigger.isConnected && menu.contains(document.activeElement)) trigger.focus();});
menu.showPopover(); trigger.setAttribute('aria-expanded', 'true');
// The native top layer escapes the scrolling sidebar; clamp the menu to the viewport.
const rect = trigger.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.margin = '0';
menu.style.left = Math.max(8, Math.min(rect.right - menu.offsetWidth, innerWidth - menu.offsetWidth - 8)) + 'px';
menu.style.top = Math.max(8, Math.min(rect.bottom, innerHeight - menu.offsetHeight - 8)) + 'px';
menu.querySelector('button:not(:disabled)')?.focus();
}
async function updateConversation(conversation, patch) {
const epoch = accountEpoch, selectionEpoch = state.epoch, user = HopeAuth.user?.id, cid = conversation.id;
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
if (!user || sidebarWrites.has(cid)) return;
if (selectedLocked(cid)) throw new Error('Wait until this conversation is idle before changing it.');
sidebarWrites.add(cid);
try {
let data;
try {data = await api('/api/conversations/' + enc(cid), 'PATCH', {revision: conversation.revision, ...patch});}
catch (error) {
if (!current()) return;
let refreshed = false;
try {const latest = await api('/api/conversations/' + enc(cid)); if (!current()) return; Object.assign(conversation, latest.conversation); if (selectionEpoch === state.epoch && cid === state.conversation?.id) selected(latest.conversation); await list(); refreshed = true;} catch {/* Keep the original mutation failure visible even if refresh also fails. */}
if (!current()) return;
throw new Error(error.message + (refreshed ? ' Conversation refreshed. Review the latest version and try again.' : ' Could not refresh the conversation. Retry when the connection is available.'));
}
if (!current()) return;
Object.assign(conversation, data.conversation);
if (selectionEpoch === state.epoch && cid === state.conversation?.id) {if ('archived' in patch && !selectedLocked(cid)) reset(); else selected(data.conversation);}
// Restart the current search/workspace/archive page: writes can change ordering and cursors.
await list();
} finally {if (current()) sidebarWrites.delete(cid);}
}
async function open(id) { async function open(id) {
closeRowMenu();
if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;} if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;}
stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls(); stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls();
const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return; const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return;
@@ -123,15 +217,15 @@
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); }; $('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
$('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));}; $('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));};
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message)); $('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
async function update(patch) {const epoch = state.epoch; let data; try {data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch});} catch (error) {await refreshSelected(false); await list(); throw new Error(error.message + ' Conversation refreshed. Review the latest version and try again.');} if (epoch !== state.epoch) return; selected(data.conversation); await list();} async function update(patch) {if (state.conversation) await updateConversation(state.conversation, patch);}
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message)); $('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
function dialog(title, value, fn, description = '', needsValue = true) { function dialog(title, value, fn, description = '', needsValue = true) {
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal(); $('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); const epoch = accountEpoch; action = value => epoch === accountEpoch ? fn(value) : undefined; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
} }
$('chatActionCancel').onclick = () => $('chatActionDialog').close(); $('chatActionCancel').onclick = () => $('chatActionDialog').close();
$('chatActionForm').onsubmit = async event => {event.preventDefault(); $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); $('chatActionDialog').close();} catch (e) {$('chatActionError').textContent = e.message;} finally {$('chatActionSave').disabled = false;} }; $('chatActionForm').onsubmit = async event => {event.preventDefault(); const epoch = accountEpoch; $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); if (epoch === accountEpoch) $('chatActionDialog').close();} catch (e) {if (epoch === accountEpoch) $('chatActionError').textContent = e.message;} finally {if (epoch === accountEpoch) $('chatActionSave').disabled = false;} };
$('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title})); $('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title}));
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', async () => {await update({archived: !state.conversation.archived}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false); $('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => update({archived: !state.conversation.archived}), 'The conversation is retained, but leaves Recent.', false);
async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');} async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');}
$('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message)); $('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message));
$('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = false;}; $('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = false;};
@@ -209,7 +303,7 @@
async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}} async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}}
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();}); document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
HopeAuth.onChange((user, changed) => { HopeAuth.onChange((user, changed) => {
if (changed || !user) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});} if (changed || !user) {++accountEpoch; closeRowMenu(); rowMenu?.remove(); rowMenu = null; sidebarWrites.clear(); $('chatActionSave').disabled = false; clearTimeout(searchTimer); clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; state.voiceActive = false; voiceSurface(false); $('voiceHistory').replaceChildren(); $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
controls(); if (!user || !changed) return; controls(); if (!user || !changed) return;
const epoch = state.epoch; syncTimer = setInterval(sync, 5000); const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);}); Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);});
+119 -46
View File
@@ -1,11 +1,40 @@
// Account-scoped advanced chat controls. Server capability flags are authoritative. // Account-scoped advanced chat controls. Server capability flags are authoritative.
(() => { (() => {
'use strict'; 'use strict';
const state = {epoch: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false}; const state = {epoch: 0, draftEpoch: 0, capabilityGeneration: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false};
const enc = encodeURIComponent, $ = id => document.getElementById(id); const enc = encodeURIComponent, $ = id => document.getElementById(id);
let options = {}, toolbar, previews, panel, status, artifactEditor, taskEditor, memoryEditor; let options = {}, toolbar, previews, panel, status, composerStatus, artifactEditor, taskEditor, memoryEditor;
const node = (tag, text, cls) => {const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (cls) el.className = cls; return el;}; const node = (tag, text, cls) => {const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (cls) el.className = cls; return el;};
const message = text => {if (status) status.textContent = text;}; let statusTarget;
const message = (text, context = 'section') => {if (status) status.textContent = text; if (context === 'composer') {if (composerStatus) composerStatus.textContent = text;} else if (statusTarget) statusTarget.textContent = text; if (!text) {document.querySelectorAll('.feature-status').forEach(el => {el.textContent = '';}); if (composerStatus) composerStatus.textContent = '';}};
let fieldSequence = 0;
const extractionBlocked = file => ['failed', 'unavailable', 'encrypted'].includes(file.extraction_status);
const imageFile = file => (file.mime || '').startsWith('image/') || file.extraction_status === 'image';
function attachmentProblem(file) {
if (!state.user || state.capabilities.attachments !== true) return 'Attachments are unavailable for this account.';
if (extractionBlocked(file)) return `Text could not be extracted from ${file.filename}. Use a UTF-8 text file instead.`;
if (imageFile(file) && state.capabilities.image_input !== true) return 'Image input is not available for the selected model.';
if (file.mime && !(state.capabilities.attachment_mimes || []).includes(file.mime)) return 'Unsupported file type: ' + file.filename;
return '';
}
function focusComposer() {
$('libraryDialog')?.close();
(options.composerInput || $('primaryInput') || $('chatInput'))?.focus();
}
async function useFile(file) {
const problem = attachmentProblem(file); if (problem) throw new Error(problem);
if (state.attachments.some(item => item.attachment?.id === file.id)) {message(file.filename + ' is already queued. Send your message when ready.', 'composer'); focusComposer(); return;}
const maximum = state.capabilities.attachments_per_message || 8;
if (state.attachments.length >= maximum) throw new Error(`Attach at most ${maximum} files per message.`);
const draftEpoch = state.draftEpoch;
const current = (await request('/api/attachments/' + enc(file.id))).attachment;
if (draftEpoch !== state.draftEpoch) return;
const currentProblem = attachmentProblem(current); if (currentProblem) throw new Error(currentProblem);
if (state.attachments.some(item => item.attachment?.id === current.id)) return;
if (state.attachments.length >= maximum) throw new Error(`Attach at most ${maximum} files per message.`);
state.attachments.push({file: {name: current.filename, size: current.size}, attachment: current, status: 'ready', error: ''});
changed(); message(current.filename + ' queued. Send your message when ready.', 'composer'); focusComposer();
}
function button(label, fn) {const el = node('button', label, 'btn btn-ghost'); el.type = 'button'; el.addEventListener('click', async () => {el.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message);} finally {el.disabled = false;}}); return el;} function button(label, fn) {const el = node('button', label, 'btn btn-ghost'); el.type = 'button'; el.addEventListener('click', async () => {el.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message);} finally {el.disabled = false;}}); return el;}
async function request(path, method = 'GET', body, extra = {}) { async function request(path, method = 'GET', body, extra = {}) {
const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller); const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller);
@@ -17,7 +46,7 @@
} }
function changed() {renderAttachments(); window.dispatchEvent(new CustomEvent('hope:attachments-changed', {detail: {ids: attachmentIDs(), pending: hasPendingUploads()}}));} function changed() {renderAttachments(); window.dispatchEvent(new CustomEvent('hope:attachments-changed', {detail: {ids: attachmentIDs(), pending: hasPendingUploads()}}));}
function attachmentIDs() {return state.attachments.filter(item => item.attachment && item.status === 'ready').map(item => item.attachment.id);} function attachmentIDs() {return state.attachments.filter(item => item.attachment && item.status === 'ready').map(item => item.attachment.id);}
function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready');} function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready' || !!attachmentProblem(item.attachment || {filename: item.file.name, mime: mime(item.file)}));}
function dispose(item) {item.controller?.abort(); if (item.url) {URL.revokeObjectURL(item.url); state.urls.delete(item.url);} } function dispose(item) {item.controller?.abort(); if (item.url) {URL.revokeObjectURL(item.url); state.urls.delete(item.url);} }
function clearAttachmentsAfterSend(ids = attachmentIDs()) {const selected = new Set(ids); state.attachments = state.attachments.filter(item => {if (selected.has(item.attachment?.id)) {dispose(item); return false;} return true;}); changed();} function clearAttachmentsAfterSend(ids = attachmentIDs()) {const selected = new Set(ids); state.attachments = state.attachments.filter(item => {if (selected.has(item.attachment?.id)) {dispose(item); return false;} return true;}); changed();}
function clearAll() {for (const item of state.attachments) dispose(item); state.attachments = []; changed();} function clearAll() {for (const item of state.attachments) dispose(item); state.attachments = []; changed();}
@@ -35,27 +64,30 @@
finally {if (epoch === state.epoch) changed();} finally {if (epoch === state.epoch) changed();}
} }
async function addFiles(files) { async function addFiles(files) {
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.'); return;} const context = $('libraryDialog')?.open ? 'section' : 'composer', accepted = [], errors = [];
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.', context); return;}
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
const maximum = state.capabilities.attachments_per_message || 8; const maximum = state.capabilities.attachments_per_message || 8;
if (state.attachments.length >= maximum) {message(`Attach at most ${maximum} files per message.`); break;} if (state.attachments.length >= maximum) {errors.push(`Attach at most ${maximum} files per message.`); break;}
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {message('Unsupported file type: ' + file.name); continue;} if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {errors.push('Unsupported file type: ' + file.name); continue;}
if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {message('Image input is not available for the selected model.'); continue;} if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {errors.push('Image input is not available for the selected model.'); continue;}
if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {message('File is empty or exceeds the upload limit: ' + file.name); continue;} if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {errors.push('File is empty or exceeds the upload limit: ' + file.name); continue;}
if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {message('File name is not supported. Rename the file and retry.'); continue;} if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {errors.push('File name is not supported. Rename the file and retry.'); continue;}
const item = {file, status: 'uploading', attachment: null, error: ''}; const item = {file, status: 'uploading', attachment: null, error: ''};
if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);} if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);}
state.attachments.push(item); upload(item); state.attachments.push(item); accepted.push(file.name); upload(item);
} }
changed(); changed();
if (accepted.length) {focusComposer(); message('Uploading ' + accepted.join(', ') + '. Send your message after uploads finish.' + (errors.length ? ' ' + errors.join(' ') : ''), 'composer');}
else if (errors.length) message(errors.join(' '), context);
} }
function renderAttachments() { function renderAttachments() {
if (!previews) return; previews.replaceChildren(); if (!previews) return; previews.replaceChildren();
for (const item of state.attachments) { for (const item of state.attachments) {
const row = node('div', undefined, 'attachment-preview'); const row = node('div', undefined, 'attachment-preview feature-row');
if (item.url) {const image = node('img'); image.src = item.url; image.alt = 'Preview of ' + item.file.name; image.width = 64; image.height = 64; row.append(image);} if (item.url) {const image = node('img'); image.src = item.url; image.alt = 'Preview of ' + item.file.name; image.width = 64; image.height = 64; row.append(image);}
row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`)); row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`));
if (item.error) row.append(node('p', item.error)); const problem = item.error || (item.status === 'ready' ? attachmentProblem(item.attachment) : ''); if (problem) row.append(node('p', problem));
if (item.attachment?.extraction_status === 'truncated') row.append(node('p', 'Only the extracted text limit will be sent.')); if (item.attachment?.extraction_status === 'truncated') row.append(node('p', 'Only the extracted text limit will be sent.'));
if (item.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort())); if (item.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort()));
if (item.status === 'failed') row.append(button('Retry upload', () => upload(item))); if (item.status === 'failed') row.append(button('Retry upload', () => upload(item)));
@@ -63,30 +95,51 @@
previews.append(row); previews.append(row);
} }
} }
function field(form, label, kind = 'input', value = '') {const wrap = node('label', label), input = node(kind); input.value = value; wrap.append(input); form.append(wrap); return input;} function field(form, label, kind = 'input', value = '', hint = '') {
function section(title) {const detail = node('details'), summary = node('summary', title), body = node('div'); detail.append(summary, body); panel.append(detail); return {detail, body};} const wrap = node('div', undefined, 'feature-field'), input = node(kind), id = 'feature-field-' + ++fieldSequence;
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.');} finally {submit.disabled = false;}});} input.id = id; input.value = value; const caption = node('label', label); caption.htmlFor = id; wrap.append(caption, input);
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; form.append(el); return el;} if (hint) {const help = node('p', hint, 'feature-hint'); help.id = id + '-hint'; input.setAttribute('aria-describedby', help.id); wrap.append(help);}
function confirmAction(container, label, description, fn) {const details = node('details'), summary = node('summary', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);} form.append(wrap); return input;
function download(path, label) {const link = node('a', label, 'btn btn-ghost'); link.href = path; link.setAttribute('download', ''); return link;} }
function section(title) {
const detail = node('details', undefined, 'feature-section'), summary = node('summary', title), body = node('div', undefined, 'feature-section-body');
const localStatus = node('p', undefined, 'feature-status'); localStatus.setAttribute('role', 'status');
detail.addEventListener('click', () => {statusTarget = localStatus;}, true); detail.addEventListener('submit', () => {statusTarget = localStatus;}, true);
const unavailable = node('p', title + ' are unavailable for this account.', 'feature-empty'); unavailable.hidden = true;
detail.append(summary, localStatus, body); panel.append(detail, unavailable); return {detail, body, unavailable};
}
function rowParts() {const row = node('div', undefined, 'feature-row'), main = node('div', undefined, 'feature-row-main'), actions = node('div', undefined, 'feature-row-actions'); row.append(main, actions); return {row, main, actions};}
function listBody(area, intro, actions, empty) {
const body = area.body, tools = node('div', undefined, 'feature-toolbar'), list = node('div', undefined, 'feature-list'); tools.append(...actions);
body.replaceChildren(node('p', intro, 'feature-intro'), tools, list); if (empty) list.append(node('p', empty, 'feature-empty')); return list;
}
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.' + (form.dataset.memory === 'true' && (error.status === 409 || /conflict/i.test(error.message)) ? ' Compare latest saved memory, review its content, then explicitly accept the reviewed revision before saving again.' : ''));} finally {submit.disabled = false;}});}
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; const actions = node('div', undefined, 'feature-form-actions'); actions.append(el); form.append(actions); return el;}
function confirmAction(container, label, description, fn) {const details = node('details', undefined, 'feature-confirm'), summary = node('summary', label.startsWith('Delete ') && label !== 'Delete task' && label !== 'Delete draft' ? 'Delete' : label); summary.setAttribute('aria-label', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);}
function download(path, label) {const link = node('a', label.startsWith('Download ') ? 'Download' : label, 'btn btn-ghost'); if (label.startsWith('Download ')) link.setAttribute('aria-label', label); link.href = path; link.setAttribute('download', ''); return link;}
const filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null}; const filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
async function loadFiles() { async function loadFiles() {
const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles)); const data = await request('/api/attachments');
if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private account until you delete them.')); const list = listBody(filesSection.value, 'Files are private account uploads you can reuse in conversations. Uploading or choosing a file only queues it; nothing is sent until you send a message.', [button('Upload files', () => {if (state.capabilities.attachments !== true) throw new Error('Attachments are unavailable for this account.'); toolbar.querySelector('input[type="file"]').click();}), button('Refresh files', loadFiles)], !data.attachments?.length ? 'No uploaded files yet. Upload a document or supported image to use it in a conversation. Files stay in your account until you delete them.' : '');
for (const file of data.attachments || []) {const row = node('div'); row.append(node('span', `${file.filename} · ${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename)); confirmAction(row, 'Delete ' + file.filename, "Deleting removes file bytes; old chat attachment references won't work.", async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message('File deleted.');}); body.append(row);} for (const file of data.attachments || []) {
const {row, main, actions} = rowParts(); main.append(node('h3', file.filename), node('p', `${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`));
const problem = attachmentProblem(file); if (problem) main.append(node('p', problem));
actions.append(button('Use in conversation', () => useFile(file)), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename));
confirmAction(actions, 'Delete ' + file.filename, `Delete “${file.filename}” from your account? Deleting removes file bytes; old chat attachment references won't work. Your conversations are not deleted.`, async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message(file.filename + ' deleted.');}); list.append(row);
}
} }
async function loadArtifacts() { async function loadArtifacts() {
const data = await request('/api/artifacts'); const body = artifactSection.value.body; body.replaceChildren(button('New artifact', () => editArtifact(null)), button('Refresh artifacts', loadArtifacts)); const data = await request('/api/artifacts');
if (!data.artifacts?.length) body.append(node('p', 'Save a draft, note or code snippet to keep versions across devices.')); const list = listBody(artifactSection.value, 'Saved drafts (artifacts) are editable notes, writing and code with saved versions. They are separate from uploaded files and are not automatically attached to a conversation.', [button('New draft', () => editArtifact(null)), button('Refresh drafts', loadArtifacts)], !data.artifacts?.length ? 'No saved drafts yet. Create a draft, note or code snippet to keep versions across devices.' : '');
for (const artifact of data.artifacts || []) {const row = node('div'); row.append(node('span', artifact.title), button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(row, 'Delete artifact', 'This removes the artifact and its saved versions.', async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message('Artifact deleted.');}); body.append(row);} for (const artifact of data.artifacts || []) {const {row, main, actions} = rowParts(); main.append(node('h3', artifact.title)); actions.append(button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(actions, 'Delete draft', `Delete “${artifact.title}”? This removes this saved draft and its versions, not conversations or uploaded files.`, async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message(artifact.title + ' deleted.');}); list.append(row);}
} }
function editArtifact(artifact) { function editArtifact(artifact) {
artifactEditor.replaceChildren(); const form = node('form'), heading = node('h3', artifact ? 'Edit artifact' : 'New artifact'); artifactEditor.replaceChildren(); const form = node('form', undefined, 'feature-form'), heading = node('h3', artifact ? 'Edit draft' : 'New draft');
const title = field(form, 'Title', 'input', artifact?.title || ''); title.required = true; title.maxLength = 160; const title = field(form, 'Title', 'input', artifact?.title || ''); title.required = true; title.maxLength = 160;
const kind = field(form, 'Format', 'select'); for (const format of ['markdown', 'text', 'code']) {const option = node('option', format); option.value = format; kind.append(option);} kind.value = artifact?.kind || 'markdown'; const kind = field(form, 'Format', 'select'); for (const format of ['markdown', 'text', 'code']) {const option = node('option', format); option.value = format; kind.append(option);} kind.value = artifact?.kind || 'markdown';
const content = field(form, 'Content', 'textarea', artifact?.content || ''); content.rows = 12; const content = field(form, 'Content', 'textarea', artifact?.content || ''); content.rows = 12;
const preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;}); const preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;});
submitButton(form, artifact ? 'Save new version' : 'Create artifact'); form.append(button('Close editor', () => artifactEditor.replaceChildren())); submitButton(form, artifact ? 'Save new version' : 'Create draft'); form.append(button('Close editor', () => artifactEditor.replaceChildren()));
if (artifact) { if (artifact) {
const versions = field(form, 'Saved version', 'select'); for (let revision = artifact.revision; revision >= 1; revision--) {const option = node('option', 'Version ' + revision); option.value = revision; versions.append(option);} const versions = field(form, 'Saved version', 'select'); for (let revision = artifact.revision; revision >= 1; revision--) {const option = node('option', 'Version ' + revision); option.value = revision; versions.append(option);}
const versionExport = download('/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value), 'Export selected saved version'); versions.addEventListener('change', () => {versionExport.href = '/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value);}); form.append(button('View saved version', async () => {const saved = (await request('/api/artifacts/' + enc(artifact.id) + '?revision=' + enc(versions.value))).artifact; preview.textContent = saved.content; message('Viewing version ' + saved.revision + '. Your current edits are unchanged.');}), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export latest saved version'), versionExport); const versionExport = download('/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value), 'Export selected saved version'); versions.addEventListener('change', () => {versionExport.href = '/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value);}); form.append(button('View saved version', async () => {const saved = (await request('/api/artifacts/' + enc(artifact.id) + '?revision=' + enc(versions.value))).artifact; preview.textContent = saved.content; message('Viewing version ' + saved.revision + '. Your current edits are unchanged.');}), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export latest saved version'), versionExport);
@@ -95,34 +148,52 @@
artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus(); artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus();
} }
async function loadTasks() { async function loadTasks() {
const data = await request('/api/tasks'), body = taskSection.value.body; body.replaceChildren(button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks)); const data = await request('/api/tasks');
if (!data.tasks?.length) body.append(node('p', 'Schedule a prompt for your private assistant. Results stay local.')); const list = listBody(taskSection.value, 'Scheduled tasks run a prompt with your private assistant. Delivery stays local; no external recipients or notifications are configured here.', [button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks)], !data.tasks?.length ? 'No scheduled tasks yet. Create one paused to review its prompt and schedule before enabling it.' : '');
for (const task of data.tasks || []) {const row = node('div'); row.append(node('h3', task.name), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt), button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');})); confirmAction(row, 'Delete task', 'This removes the scheduled task, not your conversations.', async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); body.append(row);} for (const task of data.tasks || []) {
const {row, main, actions} = rowParts(); main.append(node('h3', task.name || 'Scheduled task'), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt || 'Prompt not supplied'));
actions.append(button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');}));
confirmAction(actions, 'Delete task', `Delete “${task.name || 'Scheduled task'}”? This removes the scheduled task, not your conversations.`, async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); list.append(row);
}
} }
function editTask(task) { function editTask(task) {
taskEditor.replaceChildren(); const form = node('form'), title = field(form, 'Task name', 'input', task?.name || ''), schedule = field(form, 'Schedule', 'input', task?.schedule || ''), prompt = field(form, 'Prompt', 'textarea', task?.prompt || ''), repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat || ''); taskEditor.replaceChildren(); const form = node('form', undefined, 'feature-form');
const title = field(form, 'Task name', 'input', task?.name || '', 'A short name to recognize this task, for example “Review my notes”.');
const schedule = field(form, 'Schedule', 'input', task?.schedule || '', 'Enter a schedule accepted by your assistant, for example cron: 0 9 * * *. The server validates the schedule; timezone and next run are not inferred here.');
const prompt = field(form, 'Prompt', 'textarea', task?.prompt || '', 'What should the assistant do when this task runs? For example: “Summarize the priorities in my saved notes.”'); prompt.rows = 5;
const repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat ?? '', 'Optional positive whole-number repeat value passed to the scheduler. Leave blank to omit it; no unlimited-run behavior is assumed.');
title.maxLength = 200; schedule.maxLength = 200; prompt.maxLength = 5000; title.required = schedule.required = prompt.required = true; repeat.type = 'number'; repeat.min = 1; repeat.step = 1; title.maxLength = 200; schedule.maxLength = 200; prompt.maxLength = 5000; title.required = schedule.required = prompt.required = true; repeat.type = 'number'; repeat.min = 1; repeat.step = 1;
form.append(node('p', 'Use a schedule accepted by your assistant, such as a cron expression. Invalid schedules are rejected by the server. Delivery stays local.')); const paused = field(form, task ? 'Enabled' : 'Create paused', 'input', '', task ? 'Uncheck to pause this task.' : 'Checked by default: review the task before choosing Resume. Uncheck to create it enabled.'); paused.type = 'checkbox'; paused.checked = task ? task.enabled === true : true; paused.parentElement.classList.add('feature-checkbox');
const paused = field(form, task ? 'Enabled' : 'Create paused'); paused.type = 'checkbox'; paused.checked = task ? !!task.enabled : true;
submitButton(form, task ? 'Save task' : 'Create task'); form.append(button('Close task editor', () => taskEditor.replaceChildren())); submitButton(form, task ? 'Save task' : 'Create task'); form.append(button('Close task editor', () => taskEditor.replaceChildren()));
formSubmit(form, async () => {const data = {name: title.value.trim(), schedule: schedule.value.trim(), prompt: prompt.value.trim(), ...(task ? {enabled: paused.checked} : {paused: paused.checked})}; if (repeat.value) data.repeat = Number(repeat.value); await request('/api/tasks' + (task ? '/' + enc(task.id) : ''), task ? 'PATCH' : 'POST', data); await loadTasks(); taskEditor.replaceChildren(); message(task ? 'Task saved.' : 'Task created.');}); taskEditor.append(node('h3', task ? 'Edit scheduled task' : 'New scheduled task'), form); title.focus(); formSubmit(form, async () => {const data = {name: title.value.trim(), schedule: schedule.value.trim(), prompt: prompt.value.trim(), ...(task ? {enabled: paused.checked} : {paused: paused.checked})}; if (repeat.value) data.repeat = Number(repeat.value); await request('/api/tasks' + (task ? '/' + enc(task.id) : ''), task ? 'PATCH' : 'POST', data); await loadTasks(); taskEditor.replaceChildren(); message(task ? 'Task saved.' : 'Task created.');}); taskEditor.append(node('h3', task ? 'Edit scheduled task' : 'New scheduled task'), form); title.focus();
} }
async function loadMemory() { async function loadMemory() {
const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Saved memory changes may take effect in a later assistant session. Clearing a section does not delete your conversations.')); const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Memory is saved account context, not conversation history. Changes may take effect in a later assistant session. Clearing a section does not delete your conversations or other account data.', 'feature-intro'));
for (const section of data.sections || []) { for (const section of data.sections || []) {
const form = node('form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content); content.rows = 10; const form = node('form', undefined, 'feature-form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content, section.section === 'user' ? 'Tell the assistant about your preferences and context. For example: “Call me Sam. I prefer concise answers and work in product design.”' : 'Review durable facts and guidance the assistant keeps between sessions. For example: “Our project is called Atlas; use metric units in project notes.”'); content.rows = 10; form.dataset.memory = 'true';
submitButton(form, 'Save ' + label.toLowerCase()); confirmAction(form, 'Clear ' + label.toLowerCase(), 'This clears this saved section. Conversations and the other section stay unchanged.', async () => {const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: '', revision: section.revision}); section.revision = saved.revision; content.value = ''; message(label + ' cleared.');}); submitButton(form, 'Save ' + label.toLowerCase()); confirmAction(form, 'Clear ' + label.toLowerCase(), 'This clears this saved section. Conversations and the other section stay unchanged.', async () => {const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: '', revision: section.revision}); section.revision = saved.revision; content.value = ''; message(label + ' cleared.');});
const compare = node('div', undefined, 'feature-editor'); compare.hidden = true;
form.append(button('Compare latest saved memory', async () => {
const latest = (await request('/api/memory')).sections.find(item => item.section === section.section);
if (!latest) throw new Error('Saved memory section is unavailable. Your edits are retained.');
compare.hidden = false; compare.replaceChildren(node('h4', 'Latest saved ' + label.toLowerCase()), node('p', 'Your draft above is unchanged. Review the saved text before accepting its revision. Saving after accepting will replace this saved text with your draft.'), node('pre', latest.content), button('Use reviewed revision for next save', () => {section.revision = latest.revision; compare.hidden = true; message('Reviewed revision accepted. Your draft is unchanged; choose Save to replace the saved section.');}));
}), compare);
formSubmit(form, async () => {if (new TextEncoder().encode(content.value).length > 65536) throw new Error('Memory section exceeds 64 KB.'); const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: content.value, revision: section.revision}); section.revision = saved.revision; message(label + ' saved.');}); memoryEditor.append(form); formSubmit(form, async () => {if (new TextEncoder().encode(content.value).length > 65536) throw new Error('Memory section exceeds 64 KB.'); const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: content.value, revision: section.revision}); section.revision = saved.revision; message(label + ' saved.');}); memoryEditor.append(form);
} }
} }
async function refresh() { async function refresh() {
if (!state.user) return; if (!state.user) return;
const result = await request('/api/capabilities'); state.capabilities = result.features || result.capabilities || result; const generation = ++state.capabilityGeneration;
state.capabilities = {...state.capabilities, image_input: false}; changed();
const result = await request('/api/capabilities');
if (generation !== state.capabilityGeneration) return;
state.capabilities = result.features || result.capabilities || result;
toolbar.hidden = !state.user; const input = toolbar.querySelector('input[type="file"]'); input.accept = (state.capabilities.attachment_mimes || []).filter(type => !type.startsWith('image/') || state.capabilities.image_input === true).join(','); input.disabled = state.capabilities.attachments !== true; toolbar.hidden = !state.user; const input = toolbar.querySelector('input[type="file"]'); input.accept = (state.capabilities.attachment_mimes || []).filter(type => !type.startsWith('image/') || state.capabilities.image_input === true).join(','); input.disabled = state.capabilities.attachments !== true;
toolbar.querySelector('[data-attach]').disabled = input.disabled; toolbar.querySelector('[data-attach]').disabled = input.disabled;
filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true; filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true;
panel.hidden = !state.user; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) area.unavailable.hidden = !area.detail.hidden;
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' '); panel.hidden = !state.user; changed();
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.attachments !== true ? 'Files are unavailable.' : '', state.capabilities.artifacts !== true ? 'Saved drafts are unavailable.' : '', state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
} }
function initialize(config = {}) { function initialize(config = {}) {
options = {...options, ...config}; if (state.initialized) return; state.initialized = true; options = {...options, ...config}; if (state.initialized) return; state.initialized = true;
@@ -130,27 +201,29 @@
if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);} if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);}
if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);} if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);}
previews.setAttribute('aria-live', 'polite'); previews.setAttribute('aria-live', 'polite');
composerStatus = $('chatNotice'); if (!composerStatus) {composerStatus = node('p'); composerStatus.id = 'featureComposerStatus'; composerStatus.setAttribute('role', 'status'); previews.after(composerStatus);}
const input = node('input'); input.type = 'file'; input.multiple = true; input.hidden = true; input.setAttribute('aria-label', 'Choose attachments'); const input = node('input'); input.type = 'file'; input.multiple = true; input.hidden = true; input.setAttribute('aria-label', 'Choose attachments');
const attach = button('Attach files', () => input.click()); attach.dataset.attach = ''; input.addEventListener('change', () => {addFiles(input.files); input.value = '';}); toolbar.append(attach, input); const attach = button('Attach files', () => input.click()); attach.dataset.attach = ''; input.addEventListener('change', () => {addFiles(input.files); input.value = '';}); toolbar.append(attach, input);
panel = node('section'); panel.id = 'hopeFeatures'; panel.setAttribute('aria-label', 'Saved work and assistant controls'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel); panel = node('section'); panel.id = 'hopeFeatures'; panel.setAttribute('aria-label', 'Saved work and assistant controls'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel);
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails); status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details', undefined, 'feature-section'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor); filesSection.value = section('Files'); artifactSection.value = section('Saved drafts'); artifactEditor = node('div', undefined, 'feature-editor'); artifactSection.value.detail.append(artifactEditor);
taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor); taskSection.value = section('Scheduled tasks'); taskEditor = node('div', undefined, 'feature-editor'); taskSection.value.detail.append(taskEditor);
memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor); memorySection.value = section('Memory'); memoryEditor = node('div', undefined, 'feature-editor'); memorySection.value.detail.append(memoryEditor);
$('openLibrary')?.addEventListener('click', () => openSection('files'));
if ($('activityContent')) $('activityContent').append(taskSection.value.detail); if ($('activityContent')) $('activityContent').append(taskSection.value.detail);
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') message(error.message);});}); for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') {area.detail.querySelector('.feature-status').textContent = error.message; if (status) status.textContent = error.message;}});});
const drop = options.dropTarget || $('primaryComposer') || toolbar; const drop = options.dropTarget || $('primaryComposer') || toolbar;
drop.addEventListener('dragover', event => {if (event.dataTransfer?.types.includes('Files')) event.preventDefault();}); drop.addEventListener('dragover', event => {if (event.dataTransfer?.types.includes('Files')) event.preventDefault();});
drop.addEventListener('drop', event => {if (event.dataTransfer?.files.length) {event.preventDefault(); addFiles(event.dataTransfer.files);}}); drop.addEventListener('drop', event => {if (event.dataTransfer?.files.length) {event.preventDefault(); addFiles(event.dataTransfer.files);}});
window.addEventListener('hope:conversation-selected', event => {const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;}); window.addEventListener('hope:conversation-selected', event => {state.draftEpoch++; const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;});
HopeAuth.onChange((user, accountChanged) => { HopeAuth.onChange((user, accountChanged) => {
if (accountChanged || !user) {state.epoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');} if (accountChanged || !user) {state.epoch++; state.draftEpoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; state.conversation = null; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true; area.unavailable.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);}); state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);});
}); });
} }
function mountSection(name, target) { function mountSection(name, target) {
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null; const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
if (area && target) target.append(area.detail); if (area && target) target.append(area.detail, area.unavailable);
return area?.detail || null; return area?.detail || null;
} }
function openSection(name) { function openSection(name) {
+6 -6
View File
@@ -40,9 +40,9 @@
<div id="conversationActions" class="conversation-actions" popover="auto"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div> <div id="conversationActions" class="conversation-actions" popover="auto"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div>
<p id="accountStatus" role="status" class="chat-notice"></p><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div> <p id="accountStatus" role="status" class="chat-notice"></p><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div>
<section id="legacyVoice" class="inline-voice workspace" hidden aria-labelledby="voiceHeading"> <section id="legacyVoice" class="inline-voice workspace" hidden aria-labelledby="voiceHeading">
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Realtime conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div> <header class="voice-session-header"><div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true"></div><div class="voice-summary"><h2 id="voiceHeading">Voice conversation</h2><p id="stateLabel" aria-live="polite">Ready when you are</p><p id="modeDescription" class="sr-only">Speak naturally. You can interrupt Hope.</p><span id="thinkingChip" hidden>Thinking…</span><span id="bargeHint" hidden>Speak to interrupt</span><div id="status" class="status" role="status">Secure connection ready</div><div class="mic-meter" id="micLevel" aria-hidden="true"><span id="micMeterFill"></span></div></div><button id="settingsBtn" class="btn btn-ghost" type="button">Settings</button></header>
<div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button">Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" disabled>End</button><button id="settingsBtn" class="btn btn-ghost" type="button">Voice settings</button></div> <div id="conversation" class="live-transcript" tabindex="0" role="region" aria-labelledby="conversationHeading"><h3 id="conversationHeading" class="sr-only">Voice transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="voiceHistory"></div><p id="voiceTranscriptHint" class="voice-transcript-hint">Your live transcript will appear here when you speak.</p><div id="messages" role="log" aria-label="Live voice messages" aria-live="polite" aria-relevant="additions text"></div></div>
<div id="conversation" class="live-transcript"><h3 id="conversationHeading" class="sr-only">Live transcript</h3><span id="transcriptModeLabel" class="sr-only">Live voice transcript</span><div id="messages" aria-live="polite"></div></div> <div class="voice-controls"><button id="startBtn" class="btn btn-primary" type="button" hidden>Start voice</button><button id="muteBtn" class="btn btn-secondary" aria-label="Mute microphone" title="Mute microphone" aria-pressed="false" disabled>Mute</button><button id="stopBtn" class="btn btn-danger" type="button" disabled>End voice</button></div>
</section> </section>
<div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div> <div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div>
<form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What’s on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><div id="featureActions" class="feature-actions"></div><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Standard</option><option value="research">Research</option><option value="deep_research">Deep research</option></select><div class="composer-right"><span>Talk</span><button id="talkToHope" class="btn lightwell-button" type="button" aria-label="Start realtime voice conversation" aria-controls="legacyVoice" disabled><span class="lightwell-symbol" aria-hidden="true"></span></button><button id="cancelRun" class="btn" type="button" hidden>Stop</button><button id="primarySend" class="btn btn-primary send-button" aria-label="Send message" disabled>↑</button></div></div></form> <form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What’s on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><div id="featureActions" class="feature-actions"></div><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Standard</option><option value="research">Research</option><option value="deep_research">Deep research</option></select><div class="composer-right"><span>Talk</span><button id="talkToHope" class="btn lightwell-button" type="button" aria-label="Start realtime voice conversation" aria-controls="legacyVoice" disabled><span class="lightwell-symbol" aria-hidden="true"></span></button><button id="cancelRun" class="btn" type="button" hidden>Stop</button><button id="primarySend" class="btn btn-primary send-button" aria-label="Send message" disabled>↑</button></div></div></form>
@@ -77,8 +77,8 @@
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button> <button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div> <div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
</div></div></section> </div></div></section>
<section data-settings-panel="privacy" hidden><h3>Memory &amp; data</h3><p id="privacyIdentity"></p><p>Memory controls are available only when supported by your private assistant. Conversation deletion does not erase account memory.</p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><button id="settingsOpenLibrary" class="btn btn-secondary">Open Library</button></section> <section data-settings-panel="privacy" hidden><h3>Memory &amp; data</h3><p class="settings-intro">Choose what Hope keeps for future conversations. Saved memory is separate from your chat history and uploaded files.</p><p id="privacyIdentity"></p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><section class="data-guide" aria-labelledby="dataGuideTitle"><h4 id="dataGuideTitle">Manage your other saved data</h4><dl><div><dt>Conversations</dt><dd>Use the ••• menu beside a chat to archive it. To delete a chat, open it and use Conversation actions at the top right. Archiving keeps the chat; deleting removes it.</dd></div><div><dt>Files &amp; saved drafts</dt><dd>Review downloads and delete individual items in Library. Deleting a file also makes it unavailable to old chats that reference it.</dd></div></dl><button id="settingsOpenLibrary" class="btn btn-secondary">Manage files &amp; drafts</button></section></section>
<section data-settings-panel="security" hidden><h3>Security</h3><p>Hope uses a secure cookie session. Sign out when you leave a shared device.</p><p>Password resets are managed by an administrator and revoke existing sessions.</p></section> <section data-settings-panel="security" hidden><h3>Security</h3><p class="settings-intro">Your current connection and account access—not another set of switches to configure.</p><dl class="security-facts"><div><dt>Connected server</dt><dd id="securityServer"></dd></div><div><dt>Connection</dt><dd id="securityConnection"></dd></div><div><dt>Signed-in account</dt><dd id="securityIdentity"></dd></div><div><dt>Session access</dt><dd>Hope uses a server-managed cookie session. Signing out ends this browser’s current session.</dd></div></dl><section class="security-action"><h4>Leaving a shared device?</h4><p>Sign out so the next person cannot open your conversations or files.</p><button id="securitySignOut" data-settings-signout class="btn btn-secondary" type="button">Sign out of this browser</button></section><section class="security-action"><h4>Password or account concerns?</h4><p>Contact your Hope administrator for a password reset. A reset revokes existing sessions. Session lists, self-service password changes and two-factor settings are not available here.</p></section></section>
<section data-settings-panel="admin" hidden> <section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading"> <section data-settings-panel="admin" hidden> <section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div> <div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
<p id="adminMessage" role="status">Loading accounts…</p> <p id="adminMessage" role="status">Loading accounts…</p>
@@ -96,7 +96,7 @@
</section> </section>
</div></div></dialog> </div></div></dialog>
<div id="sheetBackdrop" class="sheet-backdrop" hidden></div> <div id="sheetBackdrop" class="sheet-backdrop" hidden></div>
<dialog id="libraryDialog" aria-labelledby="libraryTitle"><div class="dialog-head"><h2 id="libraryTitle">Library</h2><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p>Files and saved work, private to your account.</p><div id="libraryContent"></div></dialog> <dialog id="libraryDialog" class="library-window" aria-labelledby="libraryTitle"><div class="dialog-head"><div><h2 id="libraryTitle">Library</h2><p class="settings-intro">Your reference files and saved drafts.</p></div><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p class="library-guide">Upload a file, choose <strong>Use in conversation</strong>, then ask Hope about it. Files are only included when you send a message—not automatically in every chat.</p><div id="libraryContent"></div></dialog>
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity"><div class="tasks-header"><div><h2>Activity</h2><p>Progress from your private assistant.</p></div><button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">×</button></div><div class="tasks-list" id="tasksList"><div class="tasks-empty"><strong>No activity yet</strong><span>Progress appears here while Hope works. Replies stay in your conversation.</span></div></div><div id="activityContent"></div></div> <div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity"><div class="tasks-header"><div><h2>Activity</h2><p>Progress from your private assistant.</p></div><button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">×</button></div><div class="tasks-list" id="tasksList"><div class="tasks-empty"><strong>No activity yet</strong><span>Progress appears here while Hope works. Replies stay in your conversation.</span></div></div><div id="activityContent"></div></div>
<div hidden aria-hidden="true"><button id="voiceModeBtn" type="button">Voice</button><button id="textModeBtn" type="button">Text</button><button id="clearBtn" type="button">Clear</button><button id="openVoice" type="button">Voice</button><button id="backToChat" type="button">Back to chat</button><form id="chatComposer"><textarea id="chatInput"></textarea><button id="sendChatBtn" disabled>Send</button></form></div> <div hidden aria-hidden="true"><button id="voiceModeBtn" type="button">Voice</button><button id="textModeBtn" type="button">Text</button><button id="clearBtn" type="button">Clear</button><button id="openVoice" type="button">Voice</button><button id="backToChat" type="button">Back to chat</button><form id="chatComposer"><textarea id="chatInput"></textarea><button id="sendChatBtn" disabled>Send</button></form></div>
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription"> <dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
+11 -1
View File
@@ -19,7 +19,7 @@
function updateMemoryStatus() { function updateMemoryStatus() {
text('settingsMemoryStatus', !memory || memory.hidden text('settingsMemoryStatus', !memory || memory.hidden
? 'Memory controls are unavailable for this account. No memory has been changed.' ? 'Memory controls are unavailable for this account. No memory has been changed.'
: 'Review or edit your saved memory below. Clearing memory does not delete conversations.'); : '');
} }
function identity(user, changed) { function identity(user, changed) {
text('settingsAccountName', user?.display_name || user?.username || ''); text('settingsAccountName', user?.display_name || user?.username || '');
@@ -51,6 +51,10 @@
button.classList.toggle('selected', active); button.classList.toggle('selected', active);
if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current'); if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
}); });
if (window.matchMedia('(max-width: 600px)').matches) {
const selected = dialog.querySelector('[data-settings-target].selected');
selected?.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'instant'});
}
// auth.js keeps its polling and provisioning handlers; never copy them. // auth.js keeps its polling and provisioning handlers; never copy them.
if ($('adminSection')) $('adminSection').hidden = section !== 'admin'; if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin')); $('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
@@ -64,6 +68,12 @@
if (typeof loadModelSettings === 'function') loadModelSettings(); if (typeof loadModelSettings === 'function') loadModelSettings();
} }
} }
if (section === 'security') {
text('securityServer', window.location.host);
text('securityConnection', window.location.protocol === 'https:' ? 'HTTPS · connection encrypted in transit' : 'Not HTTPS · use your secure Hope address');
const user = window.HopeAuth?.user;
text('securityIdentity', user ? `${user.display_name || user.username} (@${user.username})` : 'Not signed in');
}
if (section === 'privacy') { if (section === 'privacy') {
if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null; if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
const status = $('featureStatus'); const status = $('featureStatus');
+129 -57
View File
@@ -150,7 +150,10 @@ input::placeholder,textarea::placeholder{
opacity:1} opacity:1}
.chat-sidebar nav{ .chat-sidebar nav{
flex:1; flex:1;
min-height:0;
overflow-y:auto;
margin-top:18px} margin-top:18px}
.sidebar-brand,.chat-sidebar>.new-chat,.chat-sidebar>input,.sidebar-footer{flex-shrink:0}
.chat-sidebar h2{ .chat-sidebar h2{
font-size:12px; font-size:12px;
font-weight:600; font-weight:600;
@@ -522,30 +525,28 @@ input::placeholder,textarea::placeholder{
object-fit:cover; object-fit:cover;
border-radius:6px} border-radius:6px}
.inline-voice{ .inline-voice{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--canvas)}
flex-shrink:0; .voice-session-header{display:flex;align-items:center;gap:14px;padding:14px max(24px,calc((100% - 740px)/2));border-bottom:1px solid var(--line);background:var(--hope-persona-surface);flex-shrink:0}
display:grid; .voice-session-header .led-wall{width:48px;height:48px;flex-shrink:0}
grid-template-columns:84px minmax(0,1fr) auto; .voice-summary{flex:1;min-width:0;overflow-wrap:anywhere}
align-items:center; .primary-chat[data-voice-active="true"]>#chatNotice{display:none}
gap:16px;
padding:16px 24px;
border-bottom:1px solid var(--line);
background:var(--hope-persona-surface);
max-height:38dvh;
overflow:auto}
.led-wall{ .led-wall{
width:84px; width:84px;
height:84px; height:84px;
display:grid; display:grid;
grid-template-columns:repeat(21,1fr); grid-template-columns:repeat(21,minmax(0,1fr));
grid-template-rows:repeat(21,1fr); grid-template-rows:repeat(21,minmax(0,1fr));
overflow:hidden;
place-items:center; place-items:center;
padding:4px; padding:4px;
color:var(--accent); color:var(--accent);
border-radius:12px} border-radius:12px}
.led-dot{ .led-dot{
width:var(--sz,3px); width:100%;
height:var(--sz,3px); height:100%;
max-width:3px;
max-height:3px;
padding:0;
min-width:1px; min-width:1px;
min-height:1px; min-height:1px;
border:0; border:0;
@@ -565,16 +566,8 @@ input::placeholder,textarea::placeholder{
line-height:1.6; line-height:1.6;
color:var(--ink-soft); color:var(--ink-soft);
margin-top:4px} margin-top:4px}
.voice-controls{ .voice-controls{display:flex;gap:12px;justify-content:center;flex-shrink:0;padding:14px 16px max(14px,env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--hope-persona-surface)}
display:flex; .voice-controls .btn{min-width:112px}
flex-wrap:wrap;
gap:6px;
justify-content:flex-end;
max-width:240px}
.voice-controls .btn{
font-size:12px}
.voice-controls #startBtn:disabled,.voice-controls #stopBtn:disabled{
display:none}
.mic-meter{ .mic-meter{
height:2px; height:2px;
max-width:170px; max-width:170px;
@@ -585,20 +578,19 @@ input::placeholder,textarea::placeholder{
height:100%; height:100%;
width:0; width:0;
background:var(--accent)} background:var(--accent)}
.live-transcript{ .live-transcript{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:28px max(24px,calc((100% - 740px)/2));scrollbar-gutter:stable;font-size:15px;line-height:1.8}
grid-column:2/-1; .voice-transcript-hint{color:var(--ink-soft);max-width:74ch;margin:12px 0}
font-size:13px; #voiceHistory:not(:empty){border-bottom:1px solid var(--line);margin-bottom:24px}
line-height:1.65} #voiceHistory .message-actions{display:none}
.live-transcript #messages:empty{ .message-row{max-width:74ch;margin:0 0 24px;overflow-wrap:anywhere}
display:none} .message-row:before{content:"Hope";display:block;font-size:13px;font-weight:600;margin-bottom:8px}
.message-row{ .message-row.user{margin-left:auto;max-width:85%}
margin:10px 0; .message-row.user:before{content:"You";color:var(--ink-soft)}
overflow-wrap:anywhere} .message-row.user .bubble{background:var(--hope-selected-wash);padding:14px 18px;border-radius:12px}
.message-row.user{ .message-row .bubble{white-space:pre-wrap}
font-weight:600} .message-row.partial .bubble{color:var(--ink-soft)}
.message-row.system{ .message-row.system{color:var(--ink-soft);font-size:13px}
color:var(--ink-soft); .message-row.system:before{content:none}
font-size:11px}
.audio-viz{ .audio-viz{
display:none} display:none}
@@ -840,6 +832,11 @@ dialog p{
border-left:1px solid var(--line); border-left:1px solid var(--line);
padding:22px} padding:22px}
.tasks-header{ .tasks-header{
position:sticky;
top:-22px;
background:var(--canvas);
z-index:1;
padding-top:12px;
display:flex; display:flex;
align-items:flex-start; align-items:flex-start;
justify-content:space-between; justify-content:space-between;
@@ -894,6 +891,84 @@ dialog p{
#hopeFeatures details{ #hopeFeatures details{
padding:14px 0; padding:14px 0;
border-bottom:1px solid var(--line)} border-bottom:1px solid var(--line)}
/* Feature sections retain their layout when mounted in Library, Activity or Settings. */
.feature-section{min-width:0;margin-block:20px;border-bottom:1px solid var(--line);padding-bottom:20px}
.feature-section>summary{font-size:1rem;font-weight:600;cursor:pointer;min-height:44px;align-content:center}
.feature-section-body{min-width:0;padding-top:12px}
.feature-intro,.feature-empty,.feature-status{line-height:1.6;max-width:68ch;overflow-wrap:anywhere}
.feature-intro{color:var(--ink-soft);margin-bottom:16px}
.feature-status:empty{display:none}
.feature-status{margin-block:12px}
.feature-toolbar,.feature-row-actions,.feature-form-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-block:12px}
.feature-list{display:flex;flex-direction:column;min-width:0}
.feature-row{display:flex;flex-direction:column;gap:12px;align-items:stretch;padding-block:18px;border-top:1px solid var(--line);min-width:0}
.feature-row-main{flex:1;min-width:0;overflow-wrap:anywhere}
.feature-row-main h3{font-size:1rem;margin-bottom:6px}
.feature-row-main p{line-height:1.6;color:var(--ink-soft);margin-block:6px}
.feature-row-actions{flex:0 1 auto;margin:0;max-width:100%}
.feature-form{display:flex;flex-direction:column;gap:18px;min-width:0;margin-top:16px}
.feature-field{display:flex;flex-direction:column;gap:8px;min-width:0;font-weight:500;line-height:1.5}
.feature-field input,.feature-field textarea,.feature-field select{display:block;width:100%;min-width:0;max-width:100%;padding:12px;border:1px solid var(--hope-control-border);border-radius:8px;background:var(--canvas);font-weight:400;min-height:44px}
.feature-field textarea{resize:vertical;line-height:1.6;min-height:120px}
.feature-field small{font-weight:400;color:var(--ink-soft);line-height:1.5}
.feature-checkbox{display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px 10px;align-items:start;line-height:1.5}
.feature-checkbox>input{grid-column:1;grid-row:1}
.feature-checkbox>label{grid-column:2;grid-row:1}
.feature-checkbox>.feature-hint{grid-column:2}
.feature-checkbox input[type=checkbox]{width:20px!important;min-width:20px;min-height:20px;margin:2px 0;padding:0;flex-shrink:0;accent-color:var(--accent)}
.feature-editor{min-width:0;border-top:1px solid var(--line);padding-top:20px;margin-top:16px}
.feature-editor:empty{display:none}
.feature-editor pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;line-height:1.6}
.feature-empty{padding:20px 0;color:var(--ink-soft)}
.feature-row details{width:100%;padding:0!important;margin:0;border-bottom:0!important}
.feature-section-body>.feature-intro{margin:0 0 14px!important}
.feature-section-body .feature-toolbar{margin:8px 0 12px}
.feature-row-main p{margin:4px 0!important}
.feature-row-actions{margin:0!important}
.feature-row .feature-row-actions details{flex-basis:100%}
.feature-row .feature-row-actions summary{font-size:13px;font-weight:400}
#hopeFeatures>.feature-section{padding-block:12px;margin-block:12px}
#hopeFeatures>.feature-section:first-of-type{margin-block:4px;padding-block:8px;order:3}
#hopeFeatures{display:flex;flex-direction:column}
#hopeFeatures>.feature-section{order:1}
#hopeFeatures>.feature-empty{order:1}
.library-guide{margin-block:12px 16px}
.feature-section>summary{min-height:40px}
#privacyMemory .feature-section{margin-top:12px}
#privacyMemory .feature-section-body{padding-top:0}
.feature-toolbar .btn:first-child{background:var(--hope-selected-wash);color:var(--accent);font-weight:600}
#activityContent .feature-row+.feature-editor{scroll-margin-top:20px}
.feature-row summary{min-height:44px;align-content:center;cursor:pointer}
.library-window{width:min(820px,calc(100vw - 32px));max-width:820px}
#activityContent{min-width:0;flex-shrink:0}
.tasks-list{flex-shrink:0}
.settings-window>.dialog-head{position:sticky;top:0;background:var(--canvas);z-index:1}
.library-window>.dialog-head{position:sticky;top:-24px;background:var(--canvas);z-index:1;padding-top:8px}
.library-guide{line-height:1.65;max-width:68ch;margin-block:16px 24px;color:var(--ink-soft)}
.settings-intro{line-height:1.65;max-width:65ch;color:var(--ink-soft);margin-top:8px}
.feature-hint{font-size:13px;font-weight:400;color:var(--ink-soft);line-height:1.5;margin:0!important}
#settingsMemoryStatus:empty{display:none}
@media(max-width:600px){.settings-nav{display:flex!important;flex-wrap:nowrap!important;overflow-x:auto;gap:4px}.settings-nav .btn{width:auto!important;flex:0 0 auto;white-space:nowrap;margin:0}.settings-nav .admin-link{border-top:0;margin-top:0;padding-top:10px;min-height:44px}}
.data-guide,.security-action{padding-block:24px;border-top:1px solid var(--line);margin-top:24px}
.data-guide h4,.security-action h4{font-size:1rem;margin-bottom:10px}
.data-guide p,.security-action p{line-height:1.65;max-width:65ch;margin-bottom:16px}
.data-guide dl,.security-facts{margin:20px 0;display:flex;flex-direction:column;gap:20px}
.data-guide dt,.security-facts dt{font-weight:600;margin-bottom:6px}
.data-guide dd,.security-facts dd{margin:0;line-height:1.6;color:var(--ink-soft);overflow-wrap:anywhere}
.conversation-row{display:flex;align-items:center;gap:4px;min-width:0}
.conversation-row .conversation-link{min-width:0;flex:1}
.conversation-row-actions{border:0;background:transparent;border-radius:8px;min-width:44px;min-height:44px;color:var(--ink-soft);flex-shrink:0}
.conversation-row-actions:hover,.conversation-row-actions:focus-visible{background:var(--surface-raised);color:var(--ink)}
.conversation-row-menu{position:fixed;margin:0;min-width:180px;max-width:calc(100vw - 24px);padding:8px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink)}
.conversation-row-menu .btn{display:block;width:100%;text-align:left;min-height:44px}
@media(max-width:600px){
.feature-row{flex-direction:column;gap:10px}
.feature-row-actions{width:100%}
.feature-row-actions .btn{max-width:100%;white-space:normal;overflow-wrap:anywhere}
.feature-form-actions .btn{flex:1 1 auto}
.library-window{width:calc(100vw - 20px);padding:18px}
}
#hopeFeatures summary{ #hopeFeatures summary{
cursor:pointer; cursor:pointer;
min-height:44px; min-height:44px;
@@ -999,24 +1074,12 @@ dialog p{
#researchMode{ #researchMode{
max-width:90px; max-width:90px;
font-size:11px} font-size:11px}
.inline-voice{ .voice-session-header{padding:12px 16px;gap:10px}
grid-template-columns:64px minmax(0,1fr); .voice-session-header .led-wall{width:36px;height:36px}
gap:10px; .voice-session-header #settingsBtn{padding:9px 8px;font-size:12px}
padding:12px 16px; .voice-summary #stateLabel{font-size:16px}
max-height:40dvh} .live-transcript{padding:22px 18px;font-size:14px}
.led-wall{ .message-row.user{max-width:95%}
width:64px;
height:64px}
.voice-controls{
grid-column:1/-1;
max-width:none;
justify-content:flex-start}
.live-transcript{
grid-column:1/-1}
.voice-summary #stateLabel{
font-size:16px}
.voice-summary #modeDescription{
display:none}
.settings-window>.dialog-head{ .settings-window>.dialog-head{
padding:18px} padding:18px}
.settings-layout{ .settings-layout{
@@ -1078,3 +1141,12 @@ dialog p{
gap:4px} gap:4px}
} }
@media(max-height:480px){
.chat-sidebar nav{flex:1 0 auto;min-height:max-content;overflow:visible}
.primary-chat[data-voice-active="true"] .chat-topbar{min-height:54px;padding-block:5px}
.voice-session-header{padding-block:6px}
.voice-summary h2,.voice-summary #bargeHint,.voice-summary #thinkingChip,.mic-meter{display:none}
.voice-controls{padding-block:6px}
.live-transcript{padding-block:16px}
}