Compare commits
8
Commits
d735ee72fa
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7990354a91 | ||
|
|
235dd65487 | ||
|
|
8905dc2c2a | ||
|
|
257ab48f0f | ||
|
|
ea4e191400 | ||
|
|
5a02b8f0c1 | ||
|
|
41ca590744 | ||
|
|
3183ebaea7 |
No files matched your search
@@ -12,11 +12,14 @@ jobs:
|
||||
- name: Qualify offline account and chat contracts before building or deploying
|
||||
run: |
|
||||
# No NAS, speech SDK, model calls or legacy live-default conftest.
|
||||
# Read-only source mount; test secrets and databases use container /tmp.
|
||||
docker run --rm --user "$(id -u):$(id -g)" \
|
||||
# The socket-backed runner checkout path is not a host bind source.
|
||||
# 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 \
|
||||
-v "$PWD:/src:ro" -w /src python:3.12-slim-bookworm \
|
||||
sh -eu -c '
|
||||
python:3.12-slim-bookworm sh -eu -c '
|
||||
mkdir -p /tmp/src
|
||||
tar -xf - -C /tmp/src
|
||||
cd /tmp/src
|
||||
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 scripts/generate_hope_themes.py --check
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# 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
|
||||
|
||||
User reported certificate error at iOS sign-in. Current pinned native HTTPS probe
|
||||
|
||||
@@ -103,6 +103,10 @@ class Accounts:
|
||||
os.chmod(companion, 0o600)
|
||||
|
||||
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()
|
||||
|
||||
def users(self):
|
||||
|
||||
@@ -20,10 +20,13 @@ git show 7bd893ae:Sources/Chat/ChatViews.swift
|
||||
git log --all --graph --oneline
|
||||
```
|
||||
|
||||
The source repository will be archived using authenticated `tea api` **only after**
|
||||
the import is pushed and remote ancestry/tree checks pass. Archival is reversible;
|
||||
no source repository deletion or local checkout deletion is part of this operation.
|
||||
The old local checkout is no longer the development source of truth.
|
||||
Published merge commit `d735ee72fabe87acdacc70312547dedd39102b3b` was verified
|
||||
against `origin/master`, including original-tip ancestry and native tree entries.
|
||||
After that verification, authenticated `tea api` archived `snowspeeder/hope-voice-ios`
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -30,24 +30,94 @@ final class ChatFeatureStore: ObservableObject {
|
||||
private let api: ChatTransport
|
||||
private var epoch = 0
|
||||
private var closed = false
|
||||
private var capabilityGeneration = 0
|
||||
private var loadGeneration = 0
|
||||
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 {
|
||||
guard !closed else { throw CancellationError() }
|
||||
let current = epoch
|
||||
let data = try await api.request(path, method: method, body: body, query: query)
|
||||
guard !closed, current == epoch else { throw CancellationError() }
|
||||
try Task.checkCancellation()
|
||||
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 {
|
||||
let caps: FeatureCapabilities = try await call("api/capabilities")
|
||||
guard generation == capabilityGeneration else { return false }
|
||||
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 }
|
||||
if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] }; let page: Page = try await call("api/artifacts"); artifacts = page.artifacts }
|
||||
if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] }; let page: Page = try await call("api/tasks"); tasks = page.tasks }
|
||||
if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] }; let page: Page = try await call("api/memory"); memory = page.sections }
|
||||
return true
|
||||
} catch {
|
||||
guard generation == capabilityGeneration else { return false }
|
||||
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
|
||||
} 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) {
|
||||
guard !(value is CancellationError), !closed else { return }
|
||||
@@ -55,7 +125,9 @@ final class ChatFeatureStore: ObservableObject {
|
||||
error = value.localizedDescription
|
||||
}
|
||||
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 }
|
||||
busy = true; defer { busy = false }
|
||||
do {
|
||||
@@ -68,6 +140,7 @@ final class ChatFeatureStore: ObservableObject {
|
||||
} catch { report(error); return nil }
|
||||
}
|
||||
func preview(_ attachment: ChatAttachment) async {
|
||||
guard !closed else { return }
|
||||
previewAttachment = attachment
|
||||
previewLoading = true
|
||||
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 }
|
||||
}
|
||||
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 {
|
||||
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
|
||||
} 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) }
|
||||
}
|
||||
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 {
|
||||
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) }
|
||||
func latestMemory(_ item: ChatMemory) async -> ChatMemory? {
|
||||
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 {
|
||||
@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
|
||||
@State private var importing = false
|
||||
@State private var photo: PhotosPickerItem?
|
||||
@@ -156,6 +254,15 @@ struct ChatLibraryView: View {
|
||||
@State private var name = ""
|
||||
@State private var schedule = ""
|
||||
@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 memoryContent = ""
|
||||
@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 loadingCapabilities {
|
||||
ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown."))
|
||||
} else if store.enabled.isEmpty {
|
||||
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."))
|
||||
} 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("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") {
|
||||
Section("Files") {
|
||||
Button("Import from Files", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy)
|
||||
if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Import photo", systemImage: "photo") }.disabled(store.busy) }
|
||||
if initialDestination == .files && store.enabled.contains("attachments") {
|
||||
Section("Private files") {
|
||||
Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary)
|
||||
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
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button {
|
||||
if selectedAttachmentIds.contains(item.id) { selectedAttachmentIds.remove(item.id) }
|
||||
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")))
|
||||
Text(item.filename).font(.body)
|
||||
Spacer()
|
||||
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)
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
if store.enabled.contains("artifacts") {
|
||||
Section("Artifacts") {
|
||||
Button("New artifact", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true }
|
||||
if initialDestination == .files && store.enabled.contains("artifacts") {
|
||||
Section("Saved drafts") {
|
||||
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) } } } }
|
||||
}
|
||||
}
|
||||
if store.enabled.contains("scheduled_tasks") {
|
||||
if initialDestination == .tasks && store.enabled.contains("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)
|
||||
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
|
||||
VStack(alignment: .leading) {
|
||||
Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; taskEditor = true }.frame(minHeight: 44)
|
||||
Text(item.schedule ?? "Schedule not provided").font(.caption).foregroundStyle(.secondary)
|
||||
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.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") } } }
|
||||
}.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
if store.enabled.contains("memory_controls") {
|
||||
Section("Account memory") {
|
||||
ForEach(store.memory) { item in Button(item.section.capitalized) { memory = item; memoryContent = item.content } }
|
||||
if initialDestination == .memory && store.enabled.contains("memory_controls") {
|
||||
Section("Durable account memory") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library and tools")
|
||||
.navigationTitle(initialDestination == .files ? "Library" : initialDestination == .tasks ? "Scheduled tasks" : "Memory")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
.task { await store.load(); loadingCapabilities = false }
|
||||
.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 }
|
||||
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" }
|
||||
_ = 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." }
|
||||
} }
|
||||
}
|
||||
.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() }) {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
@@ -248,20 +373,7 @@ struct ChatLibraryView: View {
|
||||
}
|
||||
.sheet(isPresented: $artifactEditor) { artifactForm }
|
||||
.sheet(isPresented: $taskEditor) { taskForm }
|
||||
.sheet(item: $memory) { item in
|
||||
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 } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $memory) { item in memoryForm(item) }
|
||||
.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)
|
||||
}
|
||||
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) } }
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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) } }
|
||||
.navigationTitle(task == nil ? "New task" : "Edit 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) } }
|
||||
Form {
|
||||
Section("Task") {
|
||||
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 {
|
||||
|
||||
@@ -20,7 +20,22 @@ struct HopeMessage: Codable, Identifiable, Equatable {
|
||||
var status: String
|
||||
var runId: String?
|
||||
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 {
|
||||
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()]) }
|
||||
var item = body; item["id"] = UUID().uuidString; item["created_at"] = now; item["updated_at"] = now
|
||||
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)
|
||||
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) }
|
||||
let q = query.first(where: { $0.name == "q" })?.value ?? ""
|
||||
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()])
|
||||
}
|
||||
if parts.count >= 3, parts[1] == "conversations", let index = conversations.firstIndex(where: { $0.id == parts[2] }) {
|
||||
|
||||
@@ -17,6 +17,9 @@ final class ChatStore: ObservableObject {
|
||||
@Published var olderMessagesCursor: String?
|
||||
private var currentQuery = ""
|
||||
private var currentArchived = false
|
||||
private var currentWorkspace: String?
|
||||
@Published private(set) var mutatingConversationIds: Set<String> = []
|
||||
private(set) var draftGeneration = 0
|
||||
private var searchGeneration = 0
|
||||
@Published var approval: (id: String, prompt: String)?
|
||||
@Published var researchAvailable = false
|
||||
@@ -31,6 +34,22 @@ final class ChatStore: ObservableObject {
|
||||
@Published var modelNote: String?
|
||||
let features: ChatFeatureStore
|
||||
@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 var generation = 0
|
||||
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) }
|
||||
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 data = try await api.request(path, method: method, body: body, query: query)
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
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 }
|
||||
currentQuery = query; currentArchived = archived
|
||||
let sameScope = currentQuery == query && currentArchived == archived && currentWorkspace == workspace
|
||||
currentQuery = query; currentArchived = archived; currentWorkspace = workspace
|
||||
searchGeneration += 1
|
||||
let search = searchGeneration
|
||||
searching = true
|
||||
defer { if search == searchGeneration { searching = false } }
|
||||
do {
|
||||
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)) }
|
||||
let page: ConversationPage = try await call("api/conversations", query: q)
|
||||
if let workspace { q.append(URLQueryItem(name: "workspace_id", value: workspace)) }
|
||||
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 }
|
||||
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 }
|
||||
nextCursor = page.nextCursor
|
||||
let spaces: WorkspacePage = try await call("api/workspaces"); workspaces = spaces.workspaces
|
||||
let prefs: PreferencesEnvelope = try await call("api/preferences"); preferences = prefs.preferences
|
||||
let spaces: WorkspacePage = try await call("api/workspaces", selectionBound: false)
|
||||
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
|
||||
} catch { await failure(error) }
|
||||
}
|
||||
func open(_ conversation: HopeConversation) async {
|
||||
guard !closed else { return }
|
||||
draftGeneration += 1
|
||||
if let selected { drafts[selected.id] = draft }
|
||||
generation += 1; stream?.cancel(); stream = nil
|
||||
selected = conversation; messages = []; runs = []; progress = nil; approval = nil; exportText = nil; exportFile = nil
|
||||
@@ -100,18 +125,26 @@ final class ChatStore: ObservableObject {
|
||||
do {
|
||||
var body: [String: Any] = [:]; if let workspace { body["workspace_id"] = workspace }
|
||||
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) }
|
||||
}
|
||||
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)
|
||||
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 }
|
||||
let payloadIdentity = content + "\n" + selectedAttachmentIds.sorted().joined(separator: ",") + String(research && researchAvailable)
|
||||
if pendingKey?.content != payloadIdentity { pendingKey = (payloadIdentity, UUID().uuidString) }
|
||||
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"])
|
||||
draftGeneration += 1
|
||||
upsert(response.message); upsert(response.run); draft = ""; drafts[selected.id] = ""; pendingKey = nil; selectedAttachmentIds = []
|
||||
if response.run.active { replay(response.run.id) }
|
||||
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) }
|
||||
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 {
|
||||
guard canMutate(conversation) else { return }
|
||||
mutatingConversationIds.insert(conversation.id)
|
||||
defer { mutatingConversationIds.remove(conversation.id) }
|
||||
var body = changes; body["revision"] = conversation.revision
|
||||
do {
|
||||
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body)
|
||||
if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[index] = response.conversation }
|
||||
if selected?.id == conversation.id { selected = response.conversation }
|
||||
let response: ConversationEnvelope = try await call("api/conversations/\(conversation.id)", method: "PATCH", body: body, selectionBound: false)
|
||||
updateMetadata(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) }
|
||||
}
|
||||
func delete(_ conversation: HopeConversation) async {
|
||||
guard canMutate(conversation) else { return }
|
||||
mutatingConversationIds.insert(conversation.id)
|
||||
defer { mutatingConversationIds.remove(conversation.id) }
|
||||
do {
|
||||
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
|
||||
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) }
|
||||
}
|
||||
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 {
|
||||
guard let selected, !busy, activeRun == nil else { return }
|
||||
guard let selected, !busy, activeRun == nil, !mutatingConversationIds.contains(selected.id) else { return }
|
||||
busy = true
|
||||
do {
|
||||
let target: HopeMessage
|
||||
@@ -202,10 +289,26 @@ final class ChatStore: ObservableObject {
|
||||
if let edited { body["content"] = edited }
|
||||
else if regenerate { body["content"] = target.content }
|
||||
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
|
||||
if let edited { draft = edited; await send() }
|
||||
else if regenerate { draft = target.content; await send() }
|
||||
if let edited { draft = edited; selectedAttachmentIds = Set(target.attachments); await send() }
|
||||
else if regenerate { draft = target.content; selectedAttachmentIds = Set(target.attachments); await send() }
|
||||
} catch { busy = false; await failure(error) }
|
||||
}
|
||||
@discardableResult
|
||||
@@ -233,6 +336,8 @@ final class ChatStore: ObservableObject {
|
||||
let _: Result = try await call("api/workspaces/\(workspace.id)", method: "DELETE")
|
||||
workspaces.removeAll { $0.id == workspace.id }
|
||||
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) }
|
||||
}
|
||||
func savePreferences(palette: String, appearance: String) async {
|
||||
@@ -246,7 +351,7 @@ final class ChatStore: ObservableObject {
|
||||
} catch { modelNote = "Server model settings are not available." }
|
||||
}
|
||||
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) }
|
||||
}
|
||||
func loadResearch() async {
|
||||
@@ -278,20 +383,20 @@ final class ChatStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
private func synchronize() async {
|
||||
func synchronize() async {
|
||||
do {
|
||||
struct Change: Decodable { let id: String; let kind: String; let deleted: Bool; let data: HopeSyncData? }
|
||||
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
|
||||
guard !result.changes.isEmpty else { return }
|
||||
for change in result.changes {
|
||||
if change.kind == "conversation" {
|
||||
if change.deleted {
|
||||
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 {
|
||||
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 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) } }
|
||||
}
|
||||
}
|
||||
// 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 }) {
|
||||
let detail: ConversationDetail = try await call("api/conversations/\(selected.id)")
|
||||
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) } }
|
||||
}
|
||||
/// 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 ChatSettingsValue: Decodable { let model: String; let thinkingLevel: String }
|
||||
|
||||
@@ -5,6 +5,12 @@ struct NativeChatView: View {
|
||||
@ObservedObject var store: ChatStore
|
||||
let talk: (String) 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 archived = false
|
||||
@State private var workspace: String?
|
||||
@@ -27,13 +33,13 @@ struct NativeChatView: View {
|
||||
if workspace != nil || archived { Section { Label(scopeLabel, systemImage: archived ? "archivebox" : "folder") } }
|
||||
if !pinned.isEmpty { Section("Pinned") { rows(pinned) } }
|
||||
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)
|
||||
.background(HopeTheme.color(.ground, palette: HopePalette(rawValue: store.preferences.palette) ?? .opal))
|
||||
.navigationTitle("Chats")
|
||||
.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 {}
|
||||
}
|
||||
.refreshable { await refresh() }
|
||||
@@ -42,15 +48,22 @@ struct NativeChatView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
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)
|
||||
Picker("Workspace", selection: $workspace) {
|
||||
Text("All chats").tag(nil as String?)
|
||||
ForEach(store.workspaces) { Text($0.name).tag(Optional($0.id)) }
|
||||
}
|
||||
Button("Library and tools") { showLibrary = true }
|
||||
Button("Manage workspaces") { showWorkspaces = true }
|
||||
Button("Appearance and model") { showPreferences = true }
|
||||
Button("Sign out", role: .destructive) { Task { await logout() } }
|
||||
}
|
||||
Button("Library", systemImage: "books.vertical") { openLibrary(.files) }
|
||||
Button("Scheduled tasks", systemImage: "calendar") { openLibrary(.tasks) }
|
||||
Button("Memory & data", systemImage: "brain") { openLibrary(.memory) }
|
||||
} label: { Image(systemName: "line.3.horizontal.decrease.circle").frame(minWidth: 44, minHeight: 44) }
|
||||
.accessibilityLabel("Chat options")
|
||||
}
|
||||
@@ -60,22 +73,32 @@ struct NativeChatView: View {
|
||||
}
|
||||
}
|
||||
.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: $showWorkspaces) { WorkspaceManagerView(store: store) }
|
||||
.alert("Rename chat", isPresented: Binding(get: { rename != nil }, set: { if !$0 { rename = nil } })) {
|
||||
TextField("Title", text: $title)
|
||||
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)
|
||||
}
|
||||
.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 }
|
||||
} 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 emptyTitle: String {
|
||||
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 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 {
|
||||
ForEach(items) { conversation in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Button {
|
||||
Task { await store.open(conversation); path = [conversation.id] }
|
||||
} label: {
|
||||
@@ -104,16 +128,61 @@ struct NativeChatView: View {
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
}.frame(minHeight: 44, alignment: .leading)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(conversation.pinned ? "Unpin" : "Pin", systemImage: conversation.pinned ? "pin.slash" : "pin") { Task { await store.patch(conversation, changes: ["pinned": !conversation.pinned]) } }
|
||||
.buttonStyle(.plain)
|
||||
.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 }
|
||||
Menu("Move to workspace") {
|
||||
Button("Outside workspaces") { Task { 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]) } } }
|
||||
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 { 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 }
|
||||
}.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 follow = false
|
||||
@State private var library = false
|
||||
@State private var attachmentGeneration = 0
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
var body: some View {
|
||||
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.selectedAttachmentIds.isEmpty {
|
||||
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) {
|
||||
Button { 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")
|
||||
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").focused($composerFocused)
|
||||
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)
|
||||
}
|
||||
.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
|
||||
NavigationStack {
|
||||
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 {
|
||||
@ObservedObject var store: ChatStore
|
||||
var selectedAttachments: [ChatAttachment] { store.features.attachments.filter { store.selectedAttachmentIds.contains($0.id) } }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
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 {
|
||||
Label(item.filename, systemImage: "doc").font(.caption).lineLimit(1)
|
||||
Label(store.queuedAttachment(id)?.filename ?? "Unavailable attachment", systemImage: "doc").font(.caption).lineLimit(1)
|
||||
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
|
||||
}, logout: {
|
||||
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") }
|
||||
.tag(2)
|
||||
@@ -83,7 +85,7 @@ struct ConversationView: View {
|
||||
.tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") }
|
||||
.tag(0)
|
||||
|
||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
|
||||
ActivityTab(taskStore: session.taskStore, settings: settings, session: session, features: chat.features)
|
||||
.tag(1)
|
||||
}
|
||||
.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 settings: SettingsStore
|
||||
@ObservedObject var session: VoiceSession
|
||||
@ObservedObject var features: ChatFeatureStore
|
||||
@State private var showSchedules = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
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 {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
|
||||
@@ -112,6 +112,9 @@ final class VoiceSession: ObservableObject {
|
||||
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.
|
||||
func bindConversation(_ id: String?) async {
|
||||
await disconnect()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ final class ChatUITests: XCTestCase {
|
||||
XCTAssertFalse(app.buttons["End conversation"].exists)
|
||||
app.navigationBars.buttons.element(boundBy: 0).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))
|
||||
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.launch()
|
||||
XCTAssertTrue(app.buttons["Chat options"].waitForExistence(timeout: 10))
|
||||
app.buttons["Chat options"].tap(); app.buttons["Library and tools"].tap()
|
||||
XCTAssertTrue(app.buttons["New artifact"].waitForExistence(timeout: 10))
|
||||
app.buttons["New artifact"].tap()
|
||||
app.buttons["Chat options"].tap(); app.buttons["Library"].tap()
|
||||
XCTAssertTrue(app.buttons["New saved draft"].waitForExistence(timeout: 10))
|
||||
app.buttons["New saved draft"].tap()
|
||||
XCTAssertTrue(app.textFields["Title"].waitForExistence(timeout: 10))
|
||||
app.textFields["Title"].tap(); app.textFields["Title"].typeText("Local artifact")
|
||||
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["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) {
|
||||
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.launch()
|
||||
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"]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: 10)); field.tap(); field.typeText("Writing")
|
||||
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.launch()
|
||||
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["Save appearance"].tap(); app.buttons["Done"].tap()
|
||||
capture(app, "Native chat list dark standard")
|
||||
@@ -81,7 +122,7 @@ final class ChatUITests: XCTestCase {
|
||||
app.launch()
|
||||
XCTAssertTrue(app.buttons["New chat"].waitForExistence(timeout: 10))
|
||||
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.buttons["Save appearance"].exists)
|
||||
capture(app, "Appearance accessibility size")
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; };
|
||||
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.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 */; };
|
||||
4B9A3D956FB6CB7F6A231955 /* AuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC751BFB00B04C73D19C714 /* AuthClient.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 */; };
|
||||
70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.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 */; };
|
||||
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -89,6 +92,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -245,7 +249,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
262B5A712708421C35ED6AC0 /* AuthTests.swift */,
|
||||
6D39D32D45947915D82E963E /* ChatFeatureParityTests.swift */,
|
||||
F2623CA741424CA9B1B6BDA0 /* ChatLiveTests.swift */,
|
||||
4FACE46B82B55BE02F40FAD2 /* ChatParityTests.swift */,
|
||||
B5B373E4A57214C0D289536B /* ChatTests.swift */,
|
||||
C8F93A6F4B4354976953F69F /* ChatTransportTests.swift */,
|
||||
BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */,
|
||||
@@ -449,7 +455,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */,
|
||||
784F521516838C0FFF168A1D /* ChatFeatureParityTests.swift in Sources */,
|
||||
A4A97EAE381B922B213128F3 /* ChatLiveTests.swift in Sources */,
|
||||
3CDB3C606956A4D9906096D5 /* ChatParityTests.swift in Sources */,
|
||||
C28D28376A22BE3981908AC2 /* ChatTests.swift in Sources */,
|
||||
B0266A37FF81F7DF7F288B37 /* ChatTransportTests.swift in Sources */,
|
||||
FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */,
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
ancestry, build paths, compatibility scope and identified functional follow-ups.
|
||||
Root theme generation now checks the native artifact; obsolete native backend
|
||||
deploy tooling fails closed. The old repository is archived only after verified
|
||||
publication of this history-preserving import.
|
||||
deploy tooling fails closed. Migration merge `d735ee7` was pushed and remote
|
||||
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
|
||||
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
|
||||
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
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,
|
||||
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>'};
|
||||
@@ -16,10 +16,11 @@ MOCK = r'''() => {
|
||||
binary:options.body instanceof File,headers:options.headers});
|
||||
if (path==='/api/capabilities') return {features:capabilities};
|
||||
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'))));
|
||||
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.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};}
|
||||
@@ -55,17 +56,165 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
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.add_script_tag(path=str(ROOT / 'web/features.js'))
|
||||
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):
|
||||
self.page.close()
|
||||
|
||||
def open_panel(self, name):
|
||||
self.page.evaluate('document.getElementById("libraryDialog").showModal()')
|
||||
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):
|
||||
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')
|
||||
@@ -82,7 +231,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
||||
|
||||
def test_retained_upload_management_delete_is_explicit(self):
|
||||
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.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'))
|
||||
@@ -116,7 +265,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
||||
self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
|
||||
|
||||
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_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')
|
||||
|
||||
@@ -74,4 +74,6 @@ def test_ci_qualification_gates_images_and_deployment_without_live_conftest():
|
||||
assert 'tests/unit/'+name in workflow
|
||||
assert 'pytest-asyncio -r control/requirements.txt' 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
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Offline cancellation/recovery regressions; no HTTP, NAS, secrets or model calls."""
|
||||
import asyncio
|
||||
import functools
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -66,6 +68,75 @@ def execution(tmp_path):
|
||||
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):
|
||||
accounts, store, owner, token, cid, rid = execution
|
||||
runs = ChatRuns(accounts, store, adapter=adapter)
|
||||
|
||||
@@ -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('#primaryChat')).to_be_visible()
|
||||
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()
|
||||
assert page.evaluate('window.startedVoice') == 'c2'
|
||||
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
|
||||
|
||||
@@ -22,6 +22,8 @@ def test_model_settings_load_save_reload_and_failure():
|
||||
url = req.request.url
|
||||
if '/api/auth/me' in url:
|
||||
req.fulfill(json={'user':account})
|
||||
elif '/api/capabilities' in url:
|
||||
req.fulfill(json={'version': 1, 'features': {}})
|
||||
elif '/api/settings/model' in url:
|
||||
calls.append(req.request.method)
|
||||
if req.request.method == 'PUT':
|
||||
@@ -41,7 +43,9 @@ def test_model_settings_load_save_reload_and_failure():
|
||||
page.goto('https://hope.test/')
|
||||
page.wait_for_load_state('networkidle')
|
||||
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('#saveModelSettings')).to_be_disabled()
|
||||
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')
|
||||
assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'}
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.locator('#closeSheetBtn').click()
|
||||
page.locator('#settingsClose').click()
|
||||
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('#thinkingLevelSelect')).to_have_value('high')
|
||||
page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True)
|
||||
page.locator('#closeSheetBtn').click()
|
||||
page.locator('#settingsClose').click()
|
||||
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('#saveModelSettings')).to_be_disabled()
|
||||
expect(page.locator('#retryModelSettings')).to_be_visible()
|
||||
|
||||
@@ -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')],
|
||||
'preferences': dict(palette='opal', appearance='light', revision=1),
|
||||
'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)))
|
||||
|
||||
@@ -32,7 +32,10 @@ def settings_page():
|
||||
request.fulfill(status=401, json={'error': 'Sign in required'}); return
|
||||
data = {'user': state['user']}
|
||||
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/preferences':
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
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}}))")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -34,6 +34,7 @@ let tokenAbort = null;
|
||||
let voiceConversationId = null;
|
||||
let voiceSessionActive = false;
|
||||
let lastVoiceState = "";
|
||||
let voiceError = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
@@ -95,7 +96,13 @@ function setTasksOpen(open) {
|
||||
tasksPanel.hidden = !open; tasksPanel.inert = !open;
|
||||
tasksPanel.classList.toggle("open", 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.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
|
||||
@@ -134,7 +141,7 @@ async function setConversationMode(mode) {
|
||||
if (mode === "voice" && previousMode === "text") {
|
||||
chatInput.blur();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -464,7 +471,15 @@ function formatTimestamp(ts) {
|
||||
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");
|
||||
wrap.className = `message-row ${msg.role}`;
|
||||
wrap.dataset.ts = formatTimestamp(msg.ts);
|
||||
@@ -486,26 +501,28 @@ function renderMessage(msg) {
|
||||
}
|
||||
wrap.appendChild(bubble);
|
||||
messagesEl.appendChild(wrap);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollTranscript(follow);
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
const follow = transcriptFollows();
|
||||
clearPartials();
|
||||
const msg = { role, text, ts: Date.now() };
|
||||
history.push(msg);
|
||||
persistHistory();
|
||||
renderMessage(msg);
|
||||
renderMessage(msg, follow);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Partial transcripts render greyed and update in place until a final arrives.
|
||||
function addPartial(role, text) {
|
||||
const follow = transcriptFollows();
|
||||
const rows = messagesEl.querySelectorAll(".message-row.partial");
|
||||
let last = null;
|
||||
for (const r of rows) last = r;
|
||||
if (last && last.dataset.role === role) {
|
||||
last.querySelector(".bubble").textContent = cleanSpokenText(text);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollTranscript(follow);
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
@@ -521,7 +538,7 @@ function addPartial(role, text) {
|
||||
bubble.textContent = cleanSpokenText(text);
|
||||
wrap.appendChild(bubble);
|
||||
messagesEl.appendChild(wrap);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
scrollTranscript(follow);
|
||||
}
|
||||
|
||||
function clearPartials() {
|
||||
@@ -530,8 +547,8 @@ function clearPartials() {
|
||||
|
||||
function renderHistory() {
|
||||
messagesEl.innerHTML = "";
|
||||
for (const msg of history) renderMessage(msg);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
for (const msg of history) renderMessage(msg, false);
|
||||
scrollTranscript(true);
|
||||
}
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
@@ -727,7 +744,18 @@ modelSelect.addEventListener("change", () => {
|
||||
thinkingLevelSelect.addEventListener("change", () => {
|
||||
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 => {
|
||||
event.preventDefault(); updateSettingsControls();
|
||||
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 });
|
||||
if (!settingsResponseIsCurrent(account, generation)) return;
|
||||
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");
|
||||
} catch (error) {
|
||||
if (!settingsResponseIsCurrent(account, generation)) return;
|
||||
@@ -1039,7 +1073,7 @@ function voiceState() {
|
||||
const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
|
||||
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
|
||||
ready, muted: micMuted, connected, agent_connected, microphone_enabled,
|
||||
conversation_id: voiceConversationId, cid: voiceConversationId };
|
||||
conversation_id: voiceConversationId, cid: voiceConversationId, error: voiceError };
|
||||
}
|
||||
function publishVoiceState() {
|
||||
const detail = voiceState();
|
||||
@@ -1089,6 +1123,7 @@ async function connectConversation() {
|
||||
const attempt = ++connectionEpoch;
|
||||
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
|
||||
voiceConversationId = window.HopeChat?.conversationId || null;
|
||||
voiceError = null; history = []; messagesEl.replaceChildren(); scrollTranscript(true);
|
||||
voiceSessionActive = true; publishVoiceState();
|
||||
startBtn.disabled = true; stopBtn.disabled = false;
|
||||
const promise = (async () => {
|
||||
@@ -1116,7 +1151,7 @@ async function connectConversation() {
|
||||
}
|
||||
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
|
||||
} 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;
|
||||
} finally { if (attempt === connectionEpoch) connectionPromise = null; }
|
||||
})();
|
||||
@@ -1158,24 +1193,13 @@ chatComposer.addEventListener("submit", async (event) => {
|
||||
const text = chatInput.value.trim();
|
||||
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
|
||||
|
||||
sendChatBtn.disabled = true;
|
||||
chatInput.disabled = true;
|
||||
try {
|
||||
if (conversationMode !== "text") await setConversationMode("text");
|
||||
await connectConversation();
|
||||
setState("thinking");
|
||||
await room.localParticipant.sendText(text, { topic: "lk.chat" });
|
||||
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();
|
||||
}
|
||||
// Compatibility composer delegates to durable HTTP chat, never voice transport.
|
||||
const input = document.getElementById("primaryInput");
|
||||
if (voiceSessionActive || input.disabled || input.value.trim()) return;
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event("input", {bubbles:true}));
|
||||
document.getElementById("primaryComposer").requestSubmit();
|
||||
chatInput.value = ""; resizeChatInput();
|
||||
});
|
||||
|
||||
// ── Stop ────────────────────────────────────────────────────────────────────
|
||||
@@ -1202,6 +1226,8 @@ muteBtn.addEventListener("click", toggleMute);
|
||||
function updateMuteIcon() {
|
||||
publishVoiceState();
|
||||
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.title = muted ? "Unmute microphone" : "Mute microphone";
|
||||
}
|
||||
@@ -1251,7 +1277,7 @@ HopeAuth.onChange((user, changed) => {
|
||||
voiceSearchEl.value = "";
|
||||
if (user) loadVoiceCatalog();
|
||||
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;
|
||||
setTasksOpen(false); closeSheet();
|
||||
}
|
||||
|
||||
+108
-14
@@ -3,16 +3,19 @@
|
||||
'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 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 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() {
|
||||
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();
|
||||
$('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;
|
||||
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
||||
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
||||
@@ -23,7 +26,51 @@
|
||||
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
||||
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.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
||||
function stopStream() { state.stream?.abort(); state.stream = null; }
|
||||
@@ -34,18 +81,17 @@
|
||||
(open ? $('drawerClose') : $('drawerOpen')).focus();
|
||||
}
|
||||
$('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)');
|
||||
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();
|
||||
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;
|
||||
const epoch = state.epoch; state.busy = true; controls();
|
||||
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 (epoch !== state.epoch) return;
|
||||
$('legacyVoice').hidden = false;
|
||||
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();}}
|
||||
}
|
||||
@@ -108,10 +154,58 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
closeRowMenu();
|
||||
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();
|
||||
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); };
|
||||
$('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));
|
||||
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));
|
||||
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();
|
||||
$('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}));
|
||||
$('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.');}
|
||||
$('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;};
|
||||
@@ -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;}}
|
||||
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
|
||||
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;
|
||||
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);});
|
||||
|
||||
+119
-46
@@ -1,11 +1,40 @@
|
||||
// Account-scoped advanced chat controls. Server capability flags are authoritative.
|
||||
(() => {
|
||||
'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);
|
||||
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 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;}
|
||||
async function request(path, method = 'GET', body, extra = {}) {
|
||||
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 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 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();}
|
||||
@@ -35,27 +64,30 @@
|
||||
finally {if (epoch === state.epoch) changed();}
|
||||
}
|
||||
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)) {
|
||||
const maximum = state.capabilities.attachments_per_message || 8;
|
||||
if (state.attachments.length >= maximum) {message(`Attach at most ${maximum} files per message.`); break;}
|
||||
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {message('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 (!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.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {message('File name is not supported. Rename the file and retry.'); continue;}
|
||||
if (state.attachments.length >= maximum) {errors.push(`Attach at most ${maximum} files per message.`); break;}
|
||||
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) {errors.push('Image input is not available for the selected model.'); 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)) {errors.push('File name is not supported. Rename the file and retry.'); continue;}
|
||||
const item = {file, status: 'uploading', attachment: null, error: ''};
|
||||
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();
|
||||
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() {
|
||||
if (!previews) return; previews.replaceChildren();
|
||||
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);}
|
||||
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.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort()));
|
||||
if (item.status === 'failed') row.append(button('Retry upload', () => upload(item)));
|
||||
@@ -63,30 +95,51 @@
|
||||
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 section(title) {const detail = node('details'), summary = node('summary', title), body = node('div'); detail.append(summary, body); panel.append(detail); return {detail, body};}
|
||||
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;}});}
|
||||
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; form.append(el); return el;}
|
||||
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);}
|
||||
function download(path, label) {const link = node('a', label, 'btn btn-ghost'); link.href = path; link.setAttribute('download', ''); return link;}
|
||||
function field(form, label, kind = 'input', value = '', hint = '') {
|
||||
const wrap = node('div', undefined, 'feature-field'), input = node(kind), id = 'feature-field-' + ++fieldSequence;
|
||||
input.id = id; input.value = value; const caption = node('label', label); caption.htmlFor = id; wrap.append(caption, input);
|
||||
if (hint) {const help = node('p', hint, 'feature-hint'); help.id = id + '-hint'; input.setAttribute('aria-describedby', help.id); wrap.append(help);}
|
||||
form.append(wrap); return input;
|
||||
}
|
||||
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};
|
||||
async function loadFiles() {
|
||||
const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles));
|
||||
if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private 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);}
|
||||
const data = await request('/api/attachments');
|
||||
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, 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() {
|
||||
const data = await request('/api/artifacts'); const body = artifactSection.value.body; body.replaceChildren(button('New artifact', () => editArtifact(null)), button('Refresh artifacts', loadArtifacts));
|
||||
if (!data.artifacts?.length) body.append(node('p', 'Save 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);}
|
||||
const data = await request('/api/artifacts');
|
||||
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, 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) {
|
||||
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 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 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) {
|
||||
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);
|
||||
@@ -95,34 +148,52 @@
|
||||
artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus();
|
||||
}
|
||||
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));
|
||||
if (!data.tasks?.length) body.append(node('p', 'Schedule a prompt for your private assistant. Results stay local.'));
|
||||
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);}
|
||||
const data = await request('/api/tasks');
|
||||
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, 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) {
|
||||
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;
|
||||
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'); paused.type = 'checkbox'; paused.checked = task ? !!task.enabled : true;
|
||||
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');
|
||||
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();
|
||||
}
|
||||
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 || []) {
|
||||
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.');});
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
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.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;
|
||||
panel.hidden = !state.user;
|
||||
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(' ');
|
||||
for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) area.unavailable.hidden = !area.detail.hidden;
|
||||
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 = {}) {
|
||||
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 (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);}
|
||||
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 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);
|
||||
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);
|
||||
filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor);
|
||||
taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor);
|
||||
memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor);
|
||||
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('Saved drafts'); artifactEditor = node('div', undefined, 'feature-editor'); artifactSection.value.detail.append(artifactEditor);
|
||||
taskSection.value = section('Scheduled tasks'); taskEditor = node('div', undefined, 'feature-editor'); taskSection.value.detail.append(taskEditor);
|
||||
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);
|
||||
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;
|
||||
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);}});
|
||||
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) => {
|
||||
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);});
|
||||
});
|
||||
}
|
||||
function mountSection(name, target) {
|
||||
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;
|
||||
}
|
||||
function openSection(name) {
|
||||
|
||||
+6
-6
@@ -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>
|
||||
<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">
|
||||
<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>
|
||||
<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"><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>
|
||||
<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 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 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>
|
||||
<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>
|
||||
@@ -77,8 +77,8 @@
|
||||
<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></div></section>
|
||||
<section data-settings-panel="privacy" hidden><h3>Memory & 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="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="privacy" hidden><h3>Memory & 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 & 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 & drafts</button></section></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">
|
||||
<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>
|
||||
@@ -96,7 +96,7 @@
|
||||
</section>
|
||||
</div></div></dialog>
|
||||
<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 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">
|
||||
|
||||
+11
-1
@@ -19,7 +19,7 @@
|
||||
function updateMemoryStatus() {
|
||||
text('settingsMemoryStatus', !memory || memory.hidden
|
||||
? '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) {
|
||||
text('settingsAccountName', user?.display_name || user?.username || '');
|
||||
@@ -51,6 +51,10 @@
|
||||
button.classList.toggle('selected', active);
|
||||
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.
|
||||
if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
|
||||
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
|
||||
@@ -64,6 +68,12 @@
|
||||
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 (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
||||
const status = $('featureStatus');
|
||||
|
||||
+129
-57
@@ -150,7 +150,10 @@ input::placeholder,textarea::placeholder{
|
||||
opacity:1}
|
||||
.chat-sidebar nav{
|
||||
flex:1;
|
||||
min-height:0;
|
||||
overflow-y:auto;
|
||||
margin-top:18px}
|
||||
.sidebar-brand,.chat-sidebar>.new-chat,.chat-sidebar>input,.sidebar-footer{flex-shrink:0}
|
||||
.chat-sidebar h2{
|
||||
font-size:12px;
|
||||
font-weight:600;
|
||||
@@ -522,30 +525,28 @@ input::placeholder,textarea::placeholder{
|
||||
object-fit:cover;
|
||||
border-radius:6px}
|
||||
|
||||
.inline-voice{
|
||||
flex-shrink:0;
|
||||
display:grid;
|
||||
grid-template-columns:84px minmax(0,1fr) auto;
|
||||
align-items:center;
|
||||
gap:16px;
|
||||
padding:16px 24px;
|
||||
border-bottom:1px solid var(--line);
|
||||
background:var(--hope-persona-surface);
|
||||
max-height:38dvh;
|
||||
overflow:auto}
|
||||
.inline-voice{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--canvas)}
|
||||
.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}
|
||||
.voice-session-header .led-wall{width:48px;height:48px;flex-shrink:0}
|
||||
.voice-summary{flex:1;min-width:0;overflow-wrap:anywhere}
|
||||
.primary-chat[data-voice-active="true"]>#chatNotice{display:none}
|
||||
.led-wall{
|
||||
width:84px;
|
||||
height:84px;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(21,1fr);
|
||||
grid-template-rows:repeat(21,1fr);
|
||||
grid-template-columns:repeat(21,minmax(0,1fr));
|
||||
grid-template-rows:repeat(21,minmax(0,1fr));
|
||||
overflow:hidden;
|
||||
place-items:center;
|
||||
padding:4px;
|
||||
color:var(--accent);
|
||||
border-radius:12px}
|
||||
.led-dot{
|
||||
width:var(--sz,3px);
|
||||
height:var(--sz,3px);
|
||||
width:100%;
|
||||
height:100%;
|
||||
max-width:3px;
|
||||
max-height:3px;
|
||||
padding:0;
|
||||
min-width:1px;
|
||||
min-height:1px;
|
||||
border:0;
|
||||
@@ -565,16 +566,8 @@ input::placeholder,textarea::placeholder{
|
||||
line-height:1.6;
|
||||
color:var(--ink-soft);
|
||||
margin-top:4px}
|
||||
.voice-controls{
|
||||
display:flex;
|
||||
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}
|
||||
.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)}
|
||||
.voice-controls .btn{min-width:112px}
|
||||
.mic-meter{
|
||||
height:2px;
|
||||
max-width:170px;
|
||||
@@ -585,20 +578,19 @@ input::placeholder,textarea::placeholder{
|
||||
height:100%;
|
||||
width:0;
|
||||
background:var(--accent)}
|
||||
.live-transcript{
|
||||
grid-column:2/-1;
|
||||
font-size:13px;
|
||||
line-height:1.65}
|
||||
.live-transcript #messages:empty{
|
||||
display:none}
|
||||
.message-row{
|
||||
margin:10px 0;
|
||||
overflow-wrap:anywhere}
|
||||
.message-row.user{
|
||||
font-weight:600}
|
||||
.message-row.system{
|
||||
color:var(--ink-soft);
|
||||
font-size:11px}
|
||||
.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}
|
||||
.voice-transcript-hint{color:var(--ink-soft);max-width:74ch;margin:12px 0}
|
||||
#voiceHistory:not(:empty){border-bottom:1px solid var(--line);margin-bottom:24px}
|
||||
#voiceHistory .message-actions{display:none}
|
||||
.message-row{max-width:74ch;margin:0 0 24px;overflow-wrap:anywhere}
|
||||
.message-row:before{content:"Hope";display:block;font-size:13px;font-weight:600;margin-bottom:8px}
|
||||
.message-row.user{margin-left:auto;max-width:85%}
|
||||
.message-row.user:before{content:"You";color:var(--ink-soft)}
|
||||
.message-row.user .bubble{background:var(--hope-selected-wash);padding:14px 18px;border-radius:12px}
|
||||
.message-row .bubble{white-space:pre-wrap}
|
||||
.message-row.partial .bubble{color:var(--ink-soft)}
|
||||
.message-row.system{color:var(--ink-soft);font-size:13px}
|
||||
.message-row.system:before{content:none}
|
||||
.audio-viz{
|
||||
display:none}
|
||||
|
||||
@@ -840,6 +832,11 @@ dialog p{
|
||||
border-left:1px solid var(--line);
|
||||
padding:22px}
|
||||
.tasks-header{
|
||||
position:sticky;
|
||||
top:-22px;
|
||||
background:var(--canvas);
|
||||
z-index:1;
|
||||
padding-top:12px;
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
@@ -894,6 +891,84 @@ dialog p{
|
||||
#hopeFeatures details{
|
||||
padding:14px 0;
|
||||
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{
|
||||
cursor:pointer;
|
||||
min-height:44px;
|
||||
@@ -999,24 +1074,12 @@ dialog p{
|
||||
#researchMode{
|
||||
max-width:90px;
|
||||
font-size:11px}
|
||||
.inline-voice{
|
||||
grid-template-columns:64px minmax(0,1fr);
|
||||
gap:10px;
|
||||
padding:12px 16px;
|
||||
max-height:40dvh}
|
||||
.led-wall{
|
||||
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}
|
||||
.voice-session-header{padding:12px 16px;gap:10px}
|
||||
.voice-session-header .led-wall{width:36px;height:36px}
|
||||
.voice-session-header #settingsBtn{padding:9px 8px;font-size:12px}
|
||||
.voice-summary #stateLabel{font-size:16px}
|
||||
.live-transcript{padding:22px 18px;font-size:14px}
|
||||
.message-row.user{max-width:95%}
|
||||
.settings-window>.dialog-head{
|
||||
padding:18px}
|
||||
.settings-layout{
|
||||
@@ -1078,3 +1141,12 @@ dialog p{
|
||||
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}
|
||||
}
|
||||
Reference in new issue
Block a user