diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a513873..bd9b84bf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,10 @@ jobs: # needs Xcode 26+. The macos-15 runner defaults to Xcode 16.4 (Swift 6.1), # so the archive fails at SPM resolution. Point the whole toolchain at the # Xcode 26.3 that's preinstalled on the image. + # Xcode 26.3's Swift 6 compiler also hits an abs() overload-ambiguity bug in + # expo-modules-jsi (patched via patches/expo-modules-jsi+*.patch — see Expo + # issue #47875). Expo's real fix is Xcode 26.4, not preinstalled on macos-15 + # yet; bump this to 26.4 and drop the patch once the runner image has it. DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer steps: - name: Warn when building a non-main ref diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ead19ad..c96763a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -147,6 +147,47 @@ jobs: with: run: npm run lint + # ── i18n ───────────────────────────────────────────────────────────────────── + # The locale gates (`i18n-completeness`, `i18n-unused-keys`, `i18n`) live at the + # root of `__tests__/`, so neither `test:unit` (`__tests__/unit`) nor + # `test:integration` (`__tests__/integration`) matches them and they never ran + # on a PR. An English-only key therefore shipped green: Type check only catches + # a *missing key* (t() is typed against the locale JSON), never a *missing + # translation*, which reaches users as silent English fallback mid-RTL layout. + i18n: + name: i18n + runs-on: ubuntu-latest + needs: [gate, setup] + steps: + - uses: actions/checkout@v5 + + - name: i18n + if: needs.gate.outputs.skip != 'true' + uses: ./.github/actions/run-ci + with: + run: npm run test:i18n + + # ── Native dependency coherence ────────────────────────────────────────────── + # Two versions of one native module resolve fine for npm and compile fine for + # Xcode, then abort at launch in dyld with "Symbol not found" — a pod built + # against one version of a shared dependency against a different version of it + # bundled. TestFlight build 173 shipped that way (expo-camera 57.0.1 against + # expo-modules-core 57.0.7) and crashed on every launch with a green CI. + # `expo-doctor` catches the duplicate at the npm level in seconds, on Linux; + # `scripts/verify-dyld-symbols.sh` is the belt-and-braces check at ship time. + native-deps: + name: Native deps + runs-on: ubuntu-latest + needs: [gate, setup] + steps: + - uses: actions/checkout@v5 + + - name: Duplicate native modules + if: needs.gate.outputs.skip != 'true' + uses: ./.github/actions/run-ci + with: + run: npm run check:native-deps + # ── Maestro E2E (iOS sim, monthly schedule only) ───────────────────────────── # Runs once per month (00:00 UTC on the 1st) via the schedule trigger above. # The macOS runner + iOS app build is ~15 minutes and ~$3/run, so this acts diff --git a/AGENTS.md b/AGENTS.md index 1ba9cf3f..9a85f321 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,60 @@ The `[skip-ci]` suffix keeps CI from re-triggering on the bump-only commit. Neve - Before running any EAS build or submit command, stop and ask the user to confirm — do not proceed automatically - Never trigger it as a side-effect of a "ship" or "commit and ship" request +--- + +## PR Titles & Branches + +Derived from the dominant pattern in PRs created 2026-07-19 → 2026-07-24 +(e.g. `#348`–`#394`, `#368`, `#376`, `#378`–`#393`). Prefer this over ad-hoc +formats. Never mention Cursor, Codex, Claude, or other AI tooling in the title, +branch name, body, or commit message. + +### Title format + +``` +type(scope): imperative summary +``` + +- **type** — one of: `feat`, `fix`, `chore`, `docs`, `test`, `ci`, `perf`, `refactor` +- **scope** — optional but preferred when clear (`session`, `terminal`, `conversation`, + `onboarding`, `servers`, `ios`, `ci`, `deps`, `e2e`, `i18n`, …) +- **summary** — imperative, lowercase start, no trailing period; describe the + user-visible or operational change, not the implementation dump + +Examples from recent history: + +- `feat(conversation): add in-chat search entry on detail screen` +- `fix(terminal): fall back to HTTP output when WS replay is blank` +- `chore(ios): bump build number to 171 [skip-ci]` +- `docs(session): correct hold_session comments to describe grace-timer behavior` +- `test(e2e): wire server drag-reorder into the mock Maestro suite` +- `ci: gate locale parity and dead keys with an i18n job` (scope may be omitted) + +Version-bump PRs keep the `[skip-ci]` suffix. Dependabot-style titles stay as +`chore(deps): …`. + +Do **not** use bracket-slug titles like `[feat][task-name]` for new PRs — that +was a short-lived experiment and does not match the repo’s established style. + +### Branch format + +``` +type/kebab-case-summary +``` + +Match the title’s type. Examples: `feat/in-chat-search`, +`fix/terminal-empty-replay-fallback`, `chore/bump-ios-version-171`, +`docs/hold-session-comments`. Never prefix with tool names (`cursor/…`, `cc/…`). + +### Base branch (this integration wave) + +For work targeting the current integration line, open PRs against +`integration-merge-354-355-376` (not `main`). Rebase onto the latest tip of that +branch before merge. Squash title must still follow `type(scope): summary`. + +--- + ## Merging PRs — Rebase + Squash, Linear History Keep `main` a straight line — one commit per PR, no merge commits. Every PR follows the same two operations, in this order: diff --git a/CLAUDE.md b/CLAUDE.md index 5645641e..2eb4e75f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,20 @@ When adding new Maestro flows: --- +## Jest — Confirm Suite Failures in Isolation + +The `SessionScreen.*` suites are heavy enough that jest's parallel workers oversubscribe some machines, so a batch run can report failures that aren't real. Verify them serially: + +```bash +npx jest --ci --runInBand --testPathPattern "SessionScreen" +``` + +**Never classify a batch failure without re-running that suite alone** — a load artifact and a genuine defect are indistinguishable in batch output. Passes alone → artifact. Fails alone → real, fix it. Both mistakes are easy: during the 2026-07-22 integration snapshot four genuinely broken suites were nearly dismissed as flakes, while two others really were artifacts. + +The load-sensitive suites, the `.claude/` worktree gotcha (`npx jest` finds **0 tests** there), and their fixes are documented in [`docs/troubleshooting.md`](./docs/troubleshooting.md) → "Jest test suites". + +--- + ## Comments — Non-Trivial Only Never add comments that restate what the code already says. Only comment when the code is complex, non-obvious, or would surprise a reader without context. @@ -173,6 +187,60 @@ To validate a build from one of these tags before merging, run the `Deploy` work --- +--- + +## PR Titles & Branches + +Derived from the dominant pattern in PRs created 2026-07-19 → 2026-07-24 +(e.g. `#348`–`#394`, `#368`, `#376`, `#378`–`#393`). Prefer this over ad-hoc +formats. Never mention Cursor, Codex, Claude, or other AI tooling in the title, +branch name, body, or commit message. + +### Title format + +``` +type(scope): imperative summary +``` + +- **type** — one of: `feat`, `fix`, `chore`, `docs`, `test`, `ci`, `perf`, `refactor` +- **scope** — optional but preferred when clear (`session`, `terminal`, `conversation`, + `onboarding`, `servers`, `ios`, `ci`, `deps`, `e2e`, `i18n`, …) +- **summary** — imperative, lowercase start, no trailing period; describe the + user-visible or operational change, not the implementation dump + +Examples from recent history: + +- `feat(conversation): add in-chat search entry on detail screen` +- `fix(terminal): fall back to HTTP output when WS replay is blank` +- `chore(ios): bump build number to 171 [skip-ci]` +- `docs(session): correct hold_session comments to describe grace-timer behavior` +- `test(e2e): wire server drag-reorder into the mock Maestro suite` +- `ci: gate locale parity and dead keys with an i18n job` (scope may be omitted) + +Version-bump PRs keep the `[skip-ci]` suffix. Dependabot-style titles stay as +`chore(deps): …`. + +Do **not** use bracket-slug titles like `[feat][task-name]` for new PRs — that +was a short-lived experiment and does not match the repo’s established style. + +### Branch format + +``` +type/kebab-case-summary +``` + +Match the title’s type. Examples: `feat/in-chat-search`, +`fix/terminal-empty-replay-fallback`, `chore/bump-ios-version-171`, +`docs/hold-session-comments`. Never prefix with tool names (`cursor/…`, `cc/…`). + +### Base branch (this integration wave) + +For work targeting the current integration line, open PRs against +`integration-merge-354-355-376` (not `main`). Rebase onto the latest tip of that +branch before merge. Squash title must still follow `type(scope): summary`. + +--- + ## Merging PRs — Rebase + Squash, Linear History Keep `main` a straight line — one commit per PR, no merge commits. Every PR follows the same two operations, in this order: diff --git a/KICKOFF-landing-runbook.md b/KICKOFF-landing-runbook.md new file mode 100644 index 00000000..e43dc4ee --- /dev/null +++ b/KICKOFF-landing-runbook.md @@ -0,0 +1,71 @@ +# Kick-off — build the landing runbook for the mobile PR chain + +**Run this from the `merge-prs-v2` worktree** (`integration-dev/v1.0.0-2026-07-22`) so every path below is relative. Paste the section below into a fresh session. + +--- + +Create a landing runbook for this repo's open PR chain — the procedure for merging the 20 open PRs onto `main` one at a time, without re-hitting the documented conflicts or the traps that produce a green signal. + +**Format to follow:** `docs/runbooks/_template.md`. Read it first; it opens with the seven rules its sections encode. `docs/runbooks/_example-streamer-land-open-prs.md` is the same template filled in for another repo — use it to see the shape of a finished runbook, but its PR numbers and paths are not ours. `docs/runbooks/README.md` explains why runbooks and merge reports are separate files. + +**Source material:** `docs/integration-merge-report-2026-07-22.md`. It is a **run log**: Run 2 is current, **Run 1 is superseded** — do not carry Run 1's resolutions forward. Link to the report by conflict letter (A–I); do not restate it. + +**Write to** `docs/runbooks/2026-07-22-land-open-prs.md`. + +## Decide these before writing the order + +**`#355` — close as superseded by `#354` (investigated, not an open question).** Both PRs are the mobile counterpart to streamer #253 and deliver the **same** live-session capability — the core feature files (`useConversationStream.ts`, `mergeLiveMessages.ts`, `lib/externalSession.ts`, `app/conversation/[id].tsx`) are byte-identical between the two tips. #355 is the *integration-testing twin*: authored on the superseded `bfc800d-2026-07-20` snapshot, so it carries pre-formed cross-PR glue — the #341∪#354 union in `services/api-client.ts` (conflict A) and #339/#341's `cache_alert` frames in `services/ws-client.ts` — that #354 lacks. That glue is only valid on a base that already has #341, and re-forms as conflict A when #341 and #354 both land on `main`; it is **not** live-session functionality. #354 is the one to land: it targets `main` and additionally carries the ar/he/ru take-over translations #355 is missing (`takeOver` keys in `locales/ar/conversation.json`: #354 has 2, #355 has 0). Closing #355 loses no capability — verified by an end-to-end contract trace: every WS event (`conversation_event`, `conversation_events`, `conversation_updated`), type (`ownership`, `processLiveness`) and error code (`CONVERSATION_BUSY`) that streamer #253 emits is consumed by #354. Re-verify before acting: if #355 has been rebased since, re-run the comparison. + +**`#291`** (typescript 6→7) is excluded by request. + +**Count:** 21 PRs are open and **20** are merged into the snapshot. The report's merge-log table lists only **19** — `#372` (`docs/jest-suite-verification`) is merged and named in the prose but missing from the table. Confirm against `git log --merges origin/main..HEAD` and make the runbook authoritative. + +## Stacked pairs + +`#341` is based on `#339`, not `main` — identify this from `baseRefName`, never from PR number. The runbook needs both `--onto` rebases and the distinction between them: the one required whenever the parent is force-pushed or gains a commit, and the one required after the parent squash-merges (because the squash produces a commit git cannot match against the originals). Note that a stacked PR may not get full CI until its base is `main`. + +## Treat every recorded resolution as perishable + +The report demonstrates this itself: `#341`'s run-1 conflict across six files **disappeared** once its branch was rebased onto `#339`. Re-verify preconditions rather than applying resolutions blind — conflict A's resolution in particular depends on a byte-identical precondition the report states explicitly. + +The same applies to the fixes pushed upstream during the run — `#346 b84f18c`, `#354 25c83b6` / `5b26bf7`, `#362 ec5260f` / `4c6a275`, `#343 393b9ca`. The runbook's correctness depends on them still being on those branches. Verify, and say in the runbook how to re-check. + +## The centrepiece: traps that produce a green signal + +**`#356` × `#360`** — a locale-key deletion git applies with **no conflict marker**. The report proves with `git merge-tree` in both directions that reordering does not help, and that the merge hands you a conflict *in the very file silently losing keys*, so it looks handled. What works: rebase, then merge only on a **fresh post-rebase green**, because Type check catches it (`lib/i18n.types.ts` makes a missing key a `TS2345`). This needs its own "Known traps" subsection with the exact post-merge check. + +**`#354` × `#346`** — `#354`'s suite omits `useNavigation`; the screen only calls it once `#346` is present. Neither PR is wrong alone. + +## Mechanics that must be stated + +Worktree **outside `.claude/`** or jest silently finds 0 tests. Each worktree needs its own `npm ci` (~3 min). Verify heavy `SessionScreen` suites with `--runInBand`, and **re-run any single failure in isolation before calling it a flake** — this run had four genuine failures nearly dismissed as flakes and two real load artifacts, and batch output cannot distinguish them. + +One post-merge action that is not a merge: **after `#368` lands, add `i18n` to the required status checks**, or the new job runs without being able to block anything. + +## Also derive + +- **Who should run it** — model *and* reasoning effort per phase, justified by what failure looks like rather than command difficulty. Raise effort where the failure mode is *absence*: the locale cluster (`#356`, `#357`, `#360`, `#362`) is where a clean merge silently deletes keys. +- **Stop points** — three lists: always-stop (irreversible/outward-facing), stop-because-this-isn't-the-described-situation, and explicitly **do-not-stop**. The third is what keeps the first two credible. +- **Pre-flight** — sweep all 21 PRs for `CONFLICTING` and red checks, and record what it catches. `gh pr view` must run **twice**: GitHub computes mergeability lazily and the first call only triggers it. + +## Done when + +Someone who has not read the merge report can land the chain from the runbook alone, and every trap that produces a green signal has an explicit "after merging X, check Y" step. + +--- + +## Follow-up PRs opened after this kick-off (session-name display) + +These are **not** part of the 20-PR chain this kick-off builds a runbook for — they are new work opened after, targeting `main` directly. +When you write `docs/runbooks/2026-07-22-land-open-prs.md`, carry this cross-repo chain into it as a separate "Follow-up PRs" section (do not fold them into the main order — different base, different set). + +Fix: interactive Claude Code conversations carried no session name (the scanner only read the `slug` field, which the human REPL never writes), so mobile showed the project name in the list, the conversation view, and the live-session view instead of a real title. + +| Order | Repo | PR | Branch | What it does | +|---|---|---|---|---| +| 1 | tb-scanner | [#53](https://github.com/RonenMars/threadbase-scanner/pull/53) | `fix/session-name-from-first-message` | Derive session name from the first user message when no `slug`. The data source. | +| 2 | tb-streamer | [#267](https://github.com/RonenMars/threadbase-streamer/pull/267) | `fix/emit-session-name` | Emit `session_name` in the conversation detail `meta` block. | +| 3 | tb-mobile | [#376](https://github.com/RonenMars/threadbase-mobile/pull/376) | `fix/session-name-display` | Read `session_name` in the list, conversation, and live-session views (user rename → session name → project name). | + +**The mobile PR (#376) is safe to land alone** — it is additive and shows nothing new until the server pipeline (scanner #53 → scanner release → streamer dep bump → streamer #267) lands. +So the *visible* result is gated on the server side, but the merge order for correctness is only: land #376 whenever; the name simply stays blank until the upstream chain completes. diff --git a/__tests__/e2e/onboarding-flow.test.tsx b/__tests__/e2e/onboarding-flow.test.tsx index 09fd3d78..ebab5b57 100644 --- a/__tests__/e2e/onboarding-flow.test.tsx +++ b/__tests__/e2e/onboarding-flow.test.tsx @@ -190,9 +190,9 @@ describe('Onboarding – error handling', () => { // ── First-launch flow ───────────────────────────────────────────────────────── describe('Onboarding – first-launch flow', () => { - it('TOTAL_STEPS is 3', () => { + it('TOTAL_STEPS is 4', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { TOTAL_STEPS } = require('@/components/onboarding/OnboardingNavigator') - expect(TOTAL_STEPS).toBe(3) + expect(TOTAL_STEPS).toBe(4) }) }) diff --git a/__tests__/i18n-unused-keys.test.ts b/__tests__/i18n-unused-keys.test.ts new file mode 100644 index 00000000..552fdacc --- /dev/null +++ b/__tests__/i18n-unused-keys.test.ts @@ -0,0 +1,90 @@ +import fs from 'fs' +import path from 'path' + +// Dead-key gate: every English key must be referenced somewhere in the source. +// Keys are read both as `t('ns:key')` and bare `t('key')` under a per-file +// namespace, so matching is on the key path alone — namespace-agnostic. +// Dynamic keys (`t(`scanner.errors.uri.${code}`)`) can't be resolved statically; +// the literal part before the first `${` is kept as a prefix that covers them. +// Keys hoisted into a const array and fed to t() later are counted too, via a +// pass over every quoted string literal in the source. + +const ROOT = path.join(__dirname, '..') +const LOCALES_DIR = path.join(ROOT, 'locales') +const REFERENCE = 'en' +const SOURCE_DIRS = ['app', 'components', 'hooks', 'lib', 'services', 'utils', 'contexts', 'stores'] +const SOURCE_EXT = /\.(ts|tsx)$/ +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/ + +// t('a.b'), t("a.b"), i18n.t('ns:a.b') — captures the quoted first argument. +const T_LITERAL = /\bt\(\s*['"]([^'"]+)['"]/g +// t(`a.b.${x}`) — captures the static prefix up to the first interpolation. +const T_TEMPLATE = /\bt\(\s*`([^`$]*)\$\{/g +// Any quoted dotted string — catches keys stored in consts before reaching t(). +const DOTTED_LITERAL = /['"]([A-Za-z][\w-]*(?:\.[\w-]+)+)['"]/g + +function walk(dir: string): string[] { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const full = path.join(dir, e.name) + if (e.isDirectory()) return e.name === 'node_modules' ? [] : walk(full) + return SOURCE_EXT.test(e.name) && !e.name.endsWith('.d.ts') ? [full] : [] + }) +} + +// Arrays are leaves: they're fetched whole via t(key, { returnObjects: true }), +// so the key is the array itself, not its indices. +function flattenKeys(obj: Record, prefix = ''): string[] { + return Object.entries(obj).flatMap(([k, v]) => { + const key = prefix ? `${prefix}.${k}` : k + return typeof v === 'object' && v !== null && !Array.isArray(v) + ? flattenKeys(v as Record, key) + : [key] + }) +} + +function collectReferences(): { exact: Set; prefixes: string[] } { + const exact = new Set() + const prefixes: string[] = [] + + for (const dir of SOURCE_DIRS) { + for (const file of walk(path.join(ROOT, dir))) { + const src = fs.readFileSync(file, 'utf8') + for (const [, raw] of src.matchAll(T_LITERAL)) { + exact.add(raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : raw) + } + for (const [, raw] of src.matchAll(T_TEMPLATE)) { + const stripped = raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : raw + if (stripped) prefixes.push(stripped) + } + for (const [, raw] of src.matchAll(DOTTED_LITERAL)) { + exact.add(raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : raw) + } + } + } + return { exact, prefixes } +} + +const namespaceFiles = fs + .readdirSync(path.join(LOCALES_DIR, REFERENCE)) + .filter((f) => f.endsWith('.json')) + .sort() + +const { exact, prefixes } = collectReferences() + +function isUsed(key: string): boolean { + const base = key.replace(PLURAL_SUFFIX, '') + return exact.has(key) || exact.has(base) || prefixes.some((p) => base.startsWith(p)) +} + +describe('i18n unused keys', () => { + it('finds t() references in the source tree', () => { + expect(exact.size).toBeGreaterThan(0) + }) + + it.each(namespaceFiles)('%s has no unused keys', (nsFile) => { + const raw = fs.readFileSync(path.join(LOCALES_DIR, REFERENCE, nsFile), 'utf8') + const keys = flattenKeys(JSON.parse(raw) as Record) + expect(keys.filter((k) => !isUsed(k)).sort()).toEqual([]) + }) +}) diff --git a/__tests__/i18n.test.ts b/__tests__/i18n.test.ts index e3085c6f..9afa2647 100644 --- a/__tests__/i18n.test.ts +++ b/__tests__/i18n.test.ts @@ -5,16 +5,16 @@ describe('i18n', () => { expect(i18n.t('common:button.cancel')).toBe('Cancel'); }); - it('returns singular session count', () => { - expect(i18n.t('sessions:list.sessionCount', { count: 1 })).toBe('1 session'); + it('returns singular prompt count', () => { + expect(i18n.t('sessions:card.prompts', { count: 1 })).toBe('1 prompt'); }); - it('returns plural session count', () => { - expect(i18n.t('sessions:list.sessionCount', { count: 3 })).toBe('3 sessions'); + it('returns plural prompt count', () => { + expect(i18n.t('sessions:card.prompts', { count: 3 })).toBe('3 prompts'); }); it('interpolates server name', () => { - expect(i18n.t('sessions:card.connectedTo', { server: 'My Mac' })).toBe('My Mac'); + expect(i18n.t('servers:error.subtitle', { server: 'My Mac' })).toContain('My Mac'); }); it('falls back to key when translation is missing', () => { diff --git a/__tests__/integration/components/BrowseRecents.test.tsx b/__tests__/integration/components/BrowseRecents.test.tsx index d7fc1274..8ab332a8 100644 --- a/__tests__/integration/components/BrowseRecents.test.tsx +++ b/__tests__/integration/components/BrowseRecents.test.tsx @@ -173,6 +173,39 @@ describe('BrowseScreen — recent directories accordion', () => { expect(queryByText('/home/user/projects/gamma')).toBeNull() }) + it('lists recent directories newest-first', async () => { + mockSessions.current = [ + makeSession({ + id: 'old', + projectPath: '/home/user/projects/older', + projectName: 'older', + startedAt: '2024-01-01T00:00:00Z', + }), + makeSession({ + id: 'new', + projectPath: '/home/user/projects/newer', + projectName: 'newer', + startedAt: '2024-01-05T00:00:00Z', + }), + makeSession({ + id: 'mid', + projectPath: '/home/user/projects/middle', + projectName: 'middle', + startedAt: '2024-01-03T00:00:00Z', + }), + ] + + const { getAllByText } = await renderScreen() + const paths = getAllByText(/\/home\/user\/projects\//).map( + (node) => node.props.children as string, + ) + expect(paths).toEqual([ + '/home/user/projects/newer', + '/home/user/projects/middle', + '/home/user/projects/older', + ]) + }) + it('caps the list at 8 entries even when more sessions exist', async () => { mockSessions.current = Array.from({ length: 12 }, (_, i) => makeSession({ @@ -182,8 +215,52 @@ describe('BrowseScreen — recent directories accordion', () => { startedAt: `2024-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, }), ) - const { getByText } = await renderScreen() + const { getByText, queryByText, getByTestId } = await renderScreen() expect(getByText('Recent directories (8)')).toBeTruthy() + // Preview shows only the 3 newest; older paths stay hidden until Display all + expect(getByText('/home/user/projects/p11')).toBeTruthy() + expect(getByText('/home/user/projects/p10')).toBeTruthy() + expect(getByText('/home/user/projects/p9')).toBeTruthy() + expect(queryByText('/home/user/projects/p8')).toBeNull() + expect(getByTestId('recent-dirs-display-all')).toBeTruthy() + }) + + it('opens Display all modal with searchable full list when more than 3 recents', async () => { + mockSessions.current = Array.from({ length: 5 }, (_, i) => + makeSession({ + id: `s${i}`, + projectPath: `/home/user/projects/p${i}`, + projectName: `p${i}`, + startedAt: `2024-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, + }), + ) + + const { getByTestId, queryByTestId, getByPlaceholderText } = await renderScreen() + + expect(queryByTestId('recent-dir-row-/home/user/projects/p1')).toBeNull() + await fireEvent.press(getByTestId('recent-dirs-display-all')) + + expect(getByTestId('recent-dirs-modal')).toBeTruthy() + expect(getByTestId('recent-dir-row-/home/user/projects/p1')).toBeTruthy() + expect(getByTestId('recent-dir-row-/home/user/projects/p0')).toBeTruthy() + + await fireEvent.changeText(getByPlaceholderText('Search locations'), 'p1') + expect(getByTestId('recent-dir-row-/home/user/projects/p1')).toBeTruthy() + expect(queryByTestId('recent-dir-row-/home/user/projects/p0')).toBeNull() + expect(queryByTestId('recent-dir-row-/home/user/projects/p4')).toBeNull() + }) + + it('hides Display all when there are 3 or fewer recent directories', async () => { + mockSessions.current = Array.from({ length: 3 }, (_, i) => + makeSession({ + id: `s${i}`, + projectPath: `/home/user/projects/p${i}`, + projectName: `p${i}`, + startedAt: `2024-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, + }), + ) + const { queryByTestId } = await renderScreen() + expect(queryByTestId('recent-dirs-display-all')).toBeNull() }) it('navigates to /session/new with the absolute path when a recent row is tapped', async () => { diff --git a/__tests__/integration/components/ConnectStepManual.test.tsx b/__tests__/integration/components/ConnectStepManual.test.tsx index 30771aa9..7d667902 100644 --- a/__tests__/integration/components/ConnectStepManual.test.tsx +++ b/__tests__/integration/components/ConnectStepManual.test.tsx @@ -19,12 +19,12 @@ describe('ConnectStep – manual mode', () => { expect(getByText('Type / paste manually')).toBeTruthy() }) - it('shows "On your Mac" section header in manual mode', async () => { + it('shows "On your computer" section header in manual mode', async () => { const { getByText } = await render( ) await fireEvent.press(getByText('Type / paste manually')) - expect(getByText('On your Mac')).toBeTruthy() + expect(getByText('On your computer')).toBeTruthy() }) it('shows copyable tb pair command in manual mode', async () => { diff --git a/__tests__/integration/components/ServerClaudeFlagsSection.test.tsx b/__tests__/integration/components/ServerClaudeFlagsSection.test.tsx new file mode 100644 index 00000000..e22ce1b6 --- /dev/null +++ b/__tests__/integration/components/ServerClaudeFlagsSection.test.tsx @@ -0,0 +1,158 @@ +/** + * ServerClaudeFlagsSection. + * + * Guards the security-relevant behaviour: + * - hidden entirely against a server that predates the feature (data === null) + * - renders controls from the SERVER-supplied registry, not a hardcoded list + * - staging a dangerous value (bypassPermissions) requires confirmation, and a + * declined confirmation must not change anything + */ +import React from 'react' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native' +import { ThemeProvider } from '@/contexts/ThemeContext' +import { ServerClaudeFlagsSection } from '@/components/servers/ServerClaudeFlagsSection' + +const mockUseClaudeFlags = jest.fn() +const mockMutate = jest.fn() +const mockConfirm = jest.fn() + +jest.mock('@/hooks/useClaudeFlags', () => ({ + useClaudeFlags: (...args: unknown[]) => mockUseClaudeFlags(...args), + useUpdateClaudeFlags: () => ({ + mutate: mockMutate, + isPending: false, + isError: false, + error: null, + }), +})) + +jest.mock('@/utils/confirmDangerousChange', () => ({ + confirmDangerousChange: (...args: unknown[]) => mockConfirm(...args), +})) + +const REGISTRY = [ + { + id: 'permissionMode', + flag: '--permission-mode', + valueType: 'enum', + enumValues: ['acceptEdits', 'bypassPermissions'], + risk: 'low', + }, + { id: 'maxBudgetUsd', flag: '--max-budget-usd', valueType: 'string', risk: 'low' }, +] + +function seed(overrides: Record = {}) { + mockUseClaudeFlags.mockReturnValue({ + data: { + registry: REGISTRY, + values: {}, + extraArgs: null, + persisted: true, + ...overrides, + }, + isLoading: false, + }) +} + +async function renderSection() { + return await render( + + + , + ) +} + +beforeEach(() => { + mockUseClaudeFlags.mockReset() + mockMutate.mockReset() + mockConfirm.mockReset() + mockConfirm.mockResolvedValue(true) +}) + +// Unmount between cases: the async staging path can leave a pending state update +// attached to the previous tree, which otherwise breaks the NEXT render. +afterEach(() => { + cleanup() +}) + +describe('ServerClaudeFlagsSection', () => { + // An older streamer 404s the endpoint; the hook maps that to null. + it('renders nothing when the server predates the feature', async () => { + mockUseClaudeFlags.mockReturnValue({ data: null, isLoading: false }) + const { queryByTestId } = await renderSection() + + expect(queryByTestId('claude-flags-save')).toBeNull() + }) + + it('renders a control per registry entry', async () => { + seed() + const { getByTestId } = await renderSection() + + expect(getByTestId('claude-flag-permissionMode-acceptEdits')).toBeTruthy() + expect(getByTestId('claude-flag-permissionMode-bypassPermissions')).toBeTruthy() + expect(getByTestId('claude-flag-maxBudgetUsd')).toBeTruthy() + }) + + it('saves a non-dangerous value without confirmation', async () => { + seed() + const { getByTestId } = await renderSection() + + await fireEvent.changeText(getByTestId('claude-flag-maxBudgetUsd'), '5') + await fireEvent.press(getByTestId('claude-flags-save')) + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()) + expect(mockConfirm).not.toHaveBeenCalled() + expect(mockMutate).toHaveBeenCalledWith({ values: { maxBudgetUsd: '5' }, extraArgs: undefined }) + }) + + it('requires confirmation before staging bypassPermissions', async () => { + seed() + const { getByTestId } = await renderSection() + + await fireEvent.press(getByTestId('claude-flag-permissionMode-bypassPermissions')) + + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()) + await fireEvent.press(getByTestId('claude-flags-save')) + await waitFor(() => expect(mockMutate).toHaveBeenCalled()) + expect(mockMutate).toHaveBeenCalledWith({ + values: { permissionMode: 'bypassPermissions' }, + extraArgs: undefined, + }) + }) + + // Declining must leave the value unset — the whole point of the gate. + it('does not stage a dangerous value when confirmation is declined', async () => { + seed() + mockConfirm.mockResolvedValue(false) + const { getByTestId } = await renderSection() + + await fireEvent.press(getByTestId('claude-flag-permissionMode-bypassPermissions')) + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()) + + await fireEvent.press(getByTestId('claude-flags-save')) + await waitFor(() => expect(mockMutate).toHaveBeenCalled()) + expect(mockMutate).toHaveBeenCalledWith({ values: {}, extraArgs: undefined }) + }) + + it('selecting a safe permission mode needs no confirmation', async () => { + seed() + const { getByTestId } = await renderSection() + + await fireEvent.press(getByTestId('claude-flag-permissionMode-acceptEdits')) + await fireEvent.press(getByTestId('claude-flags-save')) + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()) + expect(mockConfirm).not.toHaveBeenCalled() + }) + + it('sends extra args when provided', async () => { + seed() + const { getByTestId } = await renderSection() + + await fireEvent.changeText(getByTestId('claude-flag-extra-args'), '--bare') + await fireEvent.press(getByTestId('claude-flags-save')) + + await waitFor(() => expect(mockMutate).toHaveBeenCalled()) + expect(mockMutate).toHaveBeenCalledWith({ values: {}, extraArgs: '--bare' }) + }) +}) diff --git a/__tests__/integration/components/SessionCard.external.test.tsx b/__tests__/integration/components/SessionCard.external.test.tsx new file mode 100644 index 00000000..381006fc --- /dev/null +++ b/__tests__/integration/components/SessionCard.external.test.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react-native' +import { SessionCard } from '@/components/sessions/SessionCard' +import type { MultiSession } from '@/types/api' + +const mockPush = jest.fn() +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }), + useLocalSearchParams: () => ({}), +})) + +jest.mock('@/hooks/useSessionActions', () => ({ + useSessionActions: () => ({ + cancelSession: { mutate: jest.fn(), isPending: false }, + sendInput: { mutate: jest.fn(), isPending: false }, + }), +})) + +const makeSession = (overrides: Partial = {}): MultiSession => ({ + id: 'sess-1', + serverId: 'server-1', + status: 'idle', + ptyAttached: false, + projectPath: '/home/user/my-project', + projectName: 'my-project', + lastOutput: '', + elapsedMs: 1000, + promptCount: 0, + startedAt: '2024-01-01T00:00:00Z', + ...overrides, +}) + +beforeEach(() => { + mockPush.mockClear() +}) + +describe('SessionCard — external session', () => { + it('renders the distinct "External" indicator for an alive external session', async () => { + const session = makeSession({ ownership: 'external', processLiveness: 'alive' }) + const { getByText, queryByText } = await render() + expect(getByText('External')).toBeTruthy() + // Not indistinguishable from a dead idle session. + expect(queryByText('Idle')).toBeNull() + }) + + it('is visually distinct from a managed running session', async () => { + const managed = await render( + , + ) + expect(managed.getByText('Running')).toBeTruthy() + expect(managed.queryByText('External')).toBeNull() + }) + + it('routes an external row to the read-only conversation view, not /session', async () => { + const session = makeSession({ + id: 'sess-1', + ownership: 'external', + processLiveness: 'alive', + conversationId: 'conv-abc', + }) + const { getByRole } = await render() + await fireEvent.press(getByRole('button')) + expect(mockPush).toHaveBeenCalledWith('/conversation/conv-abc?server=server-1') + expect(mockPush).not.toHaveBeenCalledWith(expect.stringContaining('/session/')) + }) + + it('prefers boundConversationId when routing an external row', async () => { + const session = makeSession({ + id: 'sess-1', + ownership: 'external', + processLiveness: 'alive', + conversationId: 'conv-abc', + boundConversationId: 'rollout-xyz', + }) + const { getByRole } = await render() + await fireEvent.press(getByRole('button')) + expect(mockPush).toHaveBeenCalledWith('/conversation/rollout-xyz?server=server-1') + }) + + it('routes a managed row to the PTY session screen', async () => { + const session = makeSession({ id: 'sess-1', status: 'running', ptyAttached: true }) + const { getByRole } = await render() + await fireEvent.press(getByRole('button')) + expect(mockPush).toHaveBeenCalledWith('/session/sess-1?server=server-1') + }) + + it('renders an older-server payload (pid only, no new fields) without crashing', async () => { + // Older servers omit ownership/processLiveness/activity but still send pid + // for a discovered process — the pid fallback surfaces the alive indicator. + const session = makeSession({ pid: 4242 }) + const { getByText, queryByText } = await render() + expect(getByText('External')).toBeTruthy() + expect(queryByText('Idle')).toBeNull() + }) + + it('renders a plain older-server session (no pid, no new fields) as its status', async () => { + const session = makeSession({ status: 'idle' }) + const { getByText } = await render() + expect(getByText('Idle')).toBeTruthy() + }) +}) diff --git a/__tests__/integration/components/SessionCard.test.tsx b/__tests__/integration/components/SessionCard.test.tsx index d2743d03..17192e17 100644 --- a/__tests__/integration/components/SessionCard.test.tsx +++ b/__tests__/integration/components/SessionCard.test.tsx @@ -90,7 +90,7 @@ describe('SessionCard', () => { it('shows waiting_input status label', async () => { const { getByText } = await render() - expect(getByText('Active')).toBeTruthy() + expect(getByText('Waiting')).toBeTruthy() }) it('navigates to session detail on press', async () => { diff --git a/__tests__/integration/components/SessionRow.external.test.tsx b/__tests__/integration/components/SessionRow.external.test.tsx new file mode 100644 index 00000000..f639deeb --- /dev/null +++ b/__tests__/integration/components/SessionRow.external.test.tsx @@ -0,0 +1,114 @@ +/** + * SessionRow (project hub) — external-session routing + alive indicator (M/P3). + * + * An external row must never reach the PTY screen (whose discovered-session + * variant exposes the destructive Overtake path) and must never open the + * input-oriented long-press actions. + */ +import React from 'react' +import { render, fireEvent } from '@testing-library/react-native' +import { Alert } from 'react-native' +import { SessionRow } from '@/components/sessions/hub/SessionRow' +import type { MultiSession } from '@/types/api' + +const mockPush = jest.fn() +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }), + useLocalSearchParams: () => ({}), +})) + +const mockCancel = jest.fn() +jest.mock('@/hooks/useSessionActions', () => ({ + useSessionActions: () => ({ + cancelSession: { mutate: mockCancel, isPending: false }, + }), +})) + +const makeSession = (overrides: Partial = {}): MultiSession => ({ + id: 'sess-1', + serverId: 'server-1', + status: 'idle', + ptyAttached: false, + projectPath: '/home/user/my-project', + projectName: 'my-project', + lastOutput: '', + elapsedMs: 1000, + promptCount: 0, + startedAt: '2026-01-01T00:00:00Z', + ...overrides, +}) + +beforeEach(() => { + mockPush.mockClear() + mockCancel.mockClear() +}) + +describe('SessionRow — external session', () => { + it('marks an alive external row with the distinct EXTERNAL pill', async () => { + const { getByText, queryByText } = await render( + , + ) + expect(getByText('EXTERNAL')).toBeTruthy() + expect(queryByText('LIVE')).toBeNull() + }) + + it('marks a managed live row with the interactive LIVE pill', async () => { + const { getByText, queryByText } = await render( + , + ) + expect(getByText('LIVE')).toBeTruthy() + expect(queryByText('EXTERNAL')).toBeNull() + }) + + it('routes an external row to the read-only conversation view, not /session', async () => { + const session = makeSession({ + ownership: 'external', + processLiveness: 'alive', + conversationId: 'conv-abc', + }) + const { getByTestId } = await render() + await fireEvent.press(getByTestId('session-row-sess-1')) + expect(mockPush).toHaveBeenCalledWith('/conversation/conv-abc?server=server-1') + expect(mockPush).not.toHaveBeenCalledWith(expect.stringContaining('/session/')) + }) + + it('prefers boundConversationId when routing an external row', async () => { + const session = makeSession({ + ownership: 'external', + conversationId: 'conv-abc', + boundConversationId: 'rollout-xyz', + }) + const { getByTestId } = await render() + await fireEvent.press(getByTestId('session-row-sess-1')) + expect(mockPush).toHaveBeenCalledWith('/conversation/rollout-xyz?server=server-1') + }) + + it('routes a managed row to the PTY session screen', async () => { + const { getByTestId } = await render( + , + ) + await fireEvent.press(getByTestId('session-row-sess-1')) + expect(mockPush).toHaveBeenCalledWith('/session/sess-1?server=server-1') + }) + + it('suppresses the long-press Cancel actions for an external row', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + try { + const { getByTestId } = await render( + , + ) + await fireEvent(getByTestId('session-row-sess-1'), 'longPress') + expect(alertSpy).not.toHaveBeenCalled() + expect(mockCancel).not.toHaveBeenCalled() + } finally { + alertSpy.mockRestore() + } + }) + + it('renders an older-server payload (no new fields) without crashing', async () => { + const { getByTestId, queryByText } = await render() + expect(getByTestId('session-row-sess-1')).toBeTruthy() + expect(queryByText('EXTERNAL')).toBeNull() + expect(queryByText('LIVE')).toBeNull() + }) +}) diff --git a/__tests__/integration/components/SessionScreen.bubbleOnly.test.tsx b/__tests__/integration/components/SessionScreen.bubbleOnly.test.tsx index 07a78197..94e5f143 100644 --- a/__tests__/integration/components/SessionScreen.bubbleOnly.test.tsx +++ b/__tests__/integration/components/SessionScreen.bubbleOnly.test.tsx @@ -40,7 +40,7 @@ jest.mock('@/hooks/useSession', () => ({ }), })) jest.mock('@/hooks/useSessionActions', () => ({ - useSessionActions: () => ({ sendInput: { mutate: jest.fn() }, adoptSession: { mutate: jest.fn() }, sendKeys: { mutate: jest.fn(), isPending: false } }), + useSessionActions: () => ({ sendInput: { mutate: jest.fn() }, adoptSession: { mutate: jest.fn() }, sendKeys: { mutate: jest.fn(), isPending: false }, stopSession: { mutate: jest.fn(), isPending: false } }), })) jest.mock('@/services/ws-client', () => ({ wsManager: { @@ -77,6 +77,7 @@ jest.mock('@/stores/settings', () => ({ jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'sess-live', server: 'srv1' }), useRouter: () => ({ replace: jest.fn(), back: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), })) jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), diff --git a/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx b/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx index 0e543423..4b6d3a1f 100644 --- a/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx +++ b/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx @@ -42,6 +42,7 @@ jest.mock('@/hooks/useSessionActions', () => ({ sendInput: { mutate: jest.fn() }, sendKeys: { mutate: jest.fn(), isPending: false }, adoptSession: { mutate: jest.fn() }, + stopSession: { mutate: jest.fn(), isPending: false }, }), })) jest.mock('@/hooks/useTerminalStream', () => ({ @@ -83,6 +84,7 @@ jest.mock('@/stores/settings', () => ({ jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'b80a4f91-17f4-4375-b65f-00e46c872b01', server: 'srv1' }), useRouter: () => ({ replace: mockReplace, back: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), })) jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), diff --git a/__tests__/integration/components/SessionScreen.externalGate.test.tsx b/__tests__/integration/components/SessionScreen.externalGate.test.tsx new file mode 100644 index 00000000..64a1aa1e --- /dev/null +++ b/__tests__/integration/components/SessionScreen.externalGate.test.tsx @@ -0,0 +1,120 @@ +/** + * SessionScreen — DiscoveredSessionScreen gate (M/P3). + * + * The Overtake path (POST /adopt → SIGTERM the user's real terminal process) is + * reachable only for a non-external discovered session. For an external session + * it must be unreachable regardless of how the screen was navigated to. + */ +import React from 'react' +import { render, screen } from '@testing-library/react-native' +import { createWrapper } from '@/test-utils' + +// A mutable session the mocked useSessionDetail returns; each test sets it. +let mockSessionData: Record | null = null + +jest.mock('expo-speech-recognition', () => ({ + ExpoSpeechRecognitionModule: { + requestPermissionsAsync: jest.fn().mockResolvedValue({ granted: false }), + getPermissionsAsync: jest.fn().mockResolvedValue({ granted: false }), + start: jest.fn(), + stop: jest.fn(), + }, + useSpeechRecognitionEvent: jest.fn(), +})) +jest.mock('@/components/conversation/LiveConversationView', () => ({ + LiveConversationView: () => null, +})) +jest.mock('@/components/terminal/TerminalView', () => ({ + TerminalView: () => null, +})) +jest.mock('@/components/terminal/MatrixRain', () => ({ MatrixRain: () => null })) +jest.mock('@/hooks/useSession', () => ({ + useSessionDetail: () => ({ data: mockSessionData, isLoading: false }), +})) +jest.mock('@/hooks/useSessionActions', () => ({ + useSessionActions: () => ({ + sendInput: { mutate: jest.fn() }, + adoptSession: { mutate: jest.fn(), isPending: false }, + sendKeys: { mutate: jest.fn(), isPending: false }, + stopSession: { mutate: jest.fn(), isPending: false }, + }), +})) +// The external case falls through the gate into the live PTY screen, which +// calls useTerminalStream unconditionally — stub it so the render settles. +jest.mock('@/hooks/useTerminalStream', () => ({ + useTerminalStream: () => ({ + lines: [], + isStreaming: false, + isLoadingHistory: false, + clear: jest.fn(), + }), +})) +jest.mock('@/services/ws-client', () => ({ + wsManager: { + getClient: () => null, + forceReconnect: jest.fn(), + status: () => 'connected', + onAnyStatusChange: () => () => {}, + }, +})) +jest.mock('@/stores/servers', () => ({ + useServersStore: (sel: (s: { activeServerIds: string[] }) => unknown) => + sel({ activeServerIds: ['srv1'] }), +})) +jest.mock('@/stores/loading-state', () => ({ + useLoadingStateStore: () => 0, +})) +jest.mock('@/stores/sessionNames', () => ({ + useSessionNamesStore: (sel: (s: { getName: () => undefined }) => unknown) => + sel({ getName: () => undefined }), +})) +jest.mock('@/stores/quickAccess', () => { + const store = { favorites: [], pinItem: jest.fn(), unpinItem: jest.fn() } + return { + useQuickAccessStore: (sel?: (s: typeof store) => unknown) => (sel ? sel(store) : store), + buildFavoriteId: () => 'fav-id', + } +}) +jest.mock('@/hooks/useSessionName', () => ({ + useRenameSession: () => ({ mutate: jest.fn() }), +})) +jest.mock('@/stores/settings', () => ({ + useSettingsStore: () => ({ sessionView: 'chat' }), +})) +jest.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ id: 'sess-ext', server: 'srv1' }), + useRouter: () => ({ replace: jest.fn(), back: jest.fn(), push: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), +})) +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: () => ({ invalidateQueries: jest.fn() }), +})) + +// eslint-disable-next-line import/first +import SessionDetailScreen from '@/app/session/[id]' + +const baseSession = { + id: 'sess-ext', + projectName: 'my-project', + projectPath: '/tmp/p', + promptCount: 1, + elapsedMs: 5000, + failureReason: null, + ptyAttached: false, + status: 'running', +} + +describe('SessionScreen — DiscoveredSessionScreen gate', () => { + it('shows the Overtake screen for a managed discovered session', async () => { + mockSessionData = { ...baseSession, ownership: 'managed' } + await render(, { wrapper: createWrapper() }) + expect(screen.getByText('Overtake')).toBeTruthy() + }) + + it('never shows the Overtake screen for an external session', async () => { + mockSessionData = { ...baseSession, ownership: 'external' } + await render(, { wrapper: createWrapper() }) + expect(screen.queryByText('Overtake')).toBeNull() + }) +}) diff --git a/__tests__/integration/components/SessionScreen.holdOnBackground.test.tsx b/__tests__/integration/components/SessionScreen.holdOnBackground.test.tsx index d9a6c20e..0d85f4be 100644 --- a/__tests__/integration/components/SessionScreen.holdOnBackground.test.tsx +++ b/__tests__/integration/components/SessionScreen.holdOnBackground.test.tsx @@ -2,10 +2,11 @@ * SessionScreen — hold-on-background. * * Guards: when the app goes to background, the screen proactively sends a - * { type: 'hold_session', sessionId } WS message so the server holds the PTY - * immediately instead of waiting out its ~4.5-min grace timer. Returning to - * 'active' force-reconnects (which re-subscribes and resumes). No message is - * sent for other transitions (e.g. 'inactive'). + * { type: 'hold_session', sessionId } WS message so the server (re)arms its + * ~4.5-min grace timer for this session, same as a WS disconnect would — the + * session keeps running until the timer elapses. Returning to 'active' + * force-reconnects (which re-subscribes and resumes). No message is sent for + * other transitions (e.g. 'inactive'). */ import React from 'react' import { AppState } from 'react-native' @@ -61,6 +62,7 @@ jest.mock('@/hooks/useSessionActions', () => ({ sendInput: { mutate: jest.fn() }, sendKeys: { mutate: jest.fn(), isPending: false }, adoptSession: { mutate: jest.fn() }, + stopSession: { mutate: jest.fn(), isPending: false }, }), })) jest.mock('@/hooks/useTerminalStream', () => ({ @@ -102,10 +104,14 @@ jest.mock('@/stores/settings', () => ({ jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'sess-live', server: 'srv1' }), useRouter: () => ({ replace: jest.fn(), back: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), })) jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), - useQueryClient: () => ({ invalidateQueries: jest.fn() }), + useQueryClient: () => ({ + invalidateQueries: jest.fn(), + getQueryData: jest.fn(), + }), })) // eslint-disable-next-line import/first diff --git a/__tests__/integration/components/SessionScreen.pending.test.tsx b/__tests__/integration/components/SessionScreen.pending.test.tsx index f9555acf..bc1a9cf0 100644 --- a/__tests__/integration/components/SessionScreen.pending.test.tsx +++ b/__tests__/integration/components/SessionScreen.pending.test.tsx @@ -81,6 +81,7 @@ jest.mock('@/stores/settings', () => ({ useSettingsStore: () => ({ sessionView: jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'pending_sess-real-id', server: 'srv1' }), useRouter: () => ({ replace: mockReplace, back: mockBack, push: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), })) jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), @@ -90,6 +91,11 @@ jest.mock('@tanstack/react-query', () => ({ // eslint-disable-next-line import/first import SessionDetailScreen from '@/app/session/[id]' +/** Fresh fake clock per test — cumulative advances across tests were blanking the tree. */ +function installPendingFakeTimers() { + jest.useFakeTimers({ now: new Date('2024-01-01T00:00:00.000Z') }) +} + describe('SessionScreen — pending session', () => { beforeEach(() => { sessionReadyHandler = undefined @@ -107,6 +113,7 @@ describe('SessionScreen — pending session', () => { // of the next one, and so fake timers (installed per-test below) start // from a clean slate. cleanup() + jest.clearAllTimers() jest.useRealTimers() }) @@ -135,7 +142,7 @@ describe('SessionScreen — pending session', () => { }) it('shows a stuck state with View console / Stop session after 20s with no signal', async () => { - jest.useFakeTimers() + installPendingFakeTimers() await render(, { wrapper: createWrapper() }) await act(async () => { @@ -143,18 +150,23 @@ describe('SessionScreen — pending session', () => { }) expect(screen.queryByText('Starting session…')).toBeNull() + expect(screen.getByText('Wait more')).toBeTruthy() const viewConsole = screen.getByText('View console') const stop = screen.getByText('Stop session') - fireEvent.press(viewConsole) + await act(async () => { + fireEvent.press(viewConsole) + }) expect(mockReplace).toHaveBeenCalledWith('/session/sess-real-id?server=srv1') - fireEvent.press(stop) + await act(async () => { + fireEvent.press(stop) + }) expect(mockStopMutate).toHaveBeenCalled() }) it('does not show the stuck state before 20s', async () => { - jest.useFakeTimers() + installPendingFakeTimers() await render(, { wrapper: createWrapper() }) await act(async () => { @@ -166,7 +178,7 @@ describe('SessionScreen — pending session', () => { }) it('"Wait more" dismisses the stuck state and returns to the spinner', async () => { - jest.useFakeTimers() + installPendingFakeTimers() await render(, { wrapper: createWrapper() }) await act(async () => { @@ -176,7 +188,9 @@ describe('SessionScreen — pending session', () => { await act(async () => { fireEvent.press(screen.getByText('Wait more')) - // Re-arming the timer resets stuck/elapsed on the effect re-run; let it flush. + }) + // Let the re-armed 250ms interval tick once without re-entering stuck. + await act(async () => { await jest.advanceTimersByTimeAsync(250) }) diff --git a/__tests__/integration/components/SessionScreen.stopButton.test.tsx b/__tests__/integration/components/SessionScreen.stopButton.test.tsx index fb9146a2..55a1ea24 100644 --- a/__tests__/integration/components/SessionScreen.stopButton.test.tsx +++ b/__tests__/integration/components/SessionScreen.stopButton.test.tsx @@ -55,6 +55,7 @@ jest.mock('@/hooks/useSessionActions', () => ({ sendInput: { mutate: jest.fn() }, sendKeys: { mutate: mockSendKeysMutate, isPending: false }, adoptSession: { mutate: jest.fn() }, + stopSession: { mutate: jest.fn(), isPending: false }, }), })) jest.mock('@/hooks/useTerminalStream', () => ({ @@ -95,6 +96,7 @@ jest.mock('@/stores/settings', () => ({ jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'sess-live', server: 'srv1' }), useRouter: () => ({ replace: jest.fn(), back: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), })) jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), diff --git a/__tests__/integration/components/SessionScreen.waking.test.tsx b/__tests__/integration/components/SessionScreen.waking.test.tsx index e2d40536..29f34f18 100644 --- a/__tests__/integration/components/SessionScreen.waking.test.tsx +++ b/__tests__/integration/components/SessionScreen.waking.test.tsx @@ -22,6 +22,7 @@ const mockQc = { invalidateQueries: mockInvalidate, cancelQueries: jest.fn(), removeQueries: jest.fn(), + getQueryData: jest.fn(), } // Live, still-waking session: running + ptyAttached, no prompt reached yet. @@ -96,6 +97,7 @@ jest.mock('@/stores/settings', () => ({ useSettingsStore: () => ({ sessionView: jest.mock('expo-router', () => ({ useLocalSearchParams: () => ({ id: 'sess-real-id', server: 'srv1' }), useRouter: () => ({ replace: jest.fn(), back: jest.fn(), push: jest.fn() }), + useNavigation: () => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) }), })) jest.mock('@tanstack/react-query', () => ({ ...jest.requireActual('@tanstack/react-query'), diff --git a/__tests__/integration/components/SessionStatusBadge.test.tsx b/__tests__/integration/components/SessionStatusBadge.test.tsx index 35af5661..2dedc843 100644 --- a/__tests__/integration/components/SessionStatusBadge.test.tsx +++ b/__tests__/integration/components/SessionStatusBadge.test.tsx @@ -5,7 +5,7 @@ import type { SessionStatus } from '@/types/api' const STATUSES: [SessionStatus, string][] = [ ['running', 'Running'], - ['waiting_input', 'Active'], + ['waiting_input', 'Waiting'], ['idle', 'Idle'], ] @@ -22,10 +22,41 @@ describe('SessionStatusBadge', () => { }) it('renders a dot indicator alongside the label', async () => { - // The component renders an Animated.View (dot) + Text (label) const { getByText, toJSON } = await render() expect(getByText('Running')).toBeTruthy() - // Tree should be non-null expect(toJSON()).not.toBeNull() }) + + it('renders the distinct "External" label when externalAlive', async () => { + const { getByText, queryByText } = await render( + , + ) + expect(getByText('External')).toBeTruthy() + expect(queryByText('Idle')).toBeNull() + }) + + it('is visually distinct from a managed running session', async () => { + const external = await render() + const managed = await render() + expect(external.getByText('External')).toBeTruthy() + expect(external.queryByText('Running')).toBeNull() + expect(managed.getByText('Running')).toBeTruthy() + expect(managed.queryByText('External')).toBeNull() + }) + + it('uses presentation kind from a full session payload', async () => { + const { getByTestId, getByText } = await render( + , + ) + expect(getByTestId('session-status-resumed')).toBeTruthy() + expect(getByText('Resumed')).toBeTruthy() + }) }) diff --git a/__tests__/integration/conversation-live-view.test.tsx b/__tests__/integration/conversation-live-view.test.tsx new file mode 100644 index 00000000..0719fda1 --- /dev/null +++ b/__tests__/integration/conversation-live-view.test.tsx @@ -0,0 +1,345 @@ +/** + * Read-only live conversation view (app/conversation/[id].tsx). + * + * P2.2 — external-session frames (keyed by the conversation UUID in sessionId) + * append to the transcript; frames for another conversation are ignored; a line + * that arrives over WS and again via the REST drain is not duplicated; the + * additive conversation_updated push triggers a delta drain. + * P2.1 — the freshness poll runs only while the screen is focused AND the app is + * foregrounded. + * The view is strictly read-only: no composer. + * + * useConversation is mocked (its own drain is covered by useConversations + * tests) so triggerDelta is an observable spy; useConversationStream is REAL, + * driven through the mocked ws-client. + */ +import React from 'react' +import { AppState } from 'react-native' +import { render, act, fireEvent, type RenderResult } from '@testing-library/react-native' +import { useLocalSearchParams } from 'expo-router' +import ConversationDetailScreen from '@/app/conversation/[id]' +import { useServersStore } from '@/stores/servers' +import { createWrapper } from '@/test-utils' +import type { Message } from '@/types/api' + +// ── observable delta trigger + controllable REST history ───────────────────── +const mockTriggerDelta = jest.fn() +const mockConversationRef: { current: unknown } = { current: null } + +jest.mock('@/hooks/useConversations', () => ({ + useConversation: () => ({ + data: mockConversationRef.current, + isLoading: false, + error: null, + refetch: jest.fn(), + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + fetchNewerPage: jest.fn(), + isFetchingNewerPage: false, + totalMessages: 0, + loadedMessages: 0, + triggerDelta: mockTriggerDelta, + }), +})) + +// ── ws-client: shared handler registry so getClient()'s per-call objects all +// register into one place; `emit` fans a frame out to subscribers. ──────────── +const mockWsHandlers = new Map void>>() +jest.mock('@/services/ws-client', () => ({ + wsManager: { + getClient: () => ({ + on: (type: string, handler: (msg: unknown) => void) => { + if (!mockWsHandlers.has(type)) mockWsHandlers.set(type, new Set()) + mockWsHandlers.get(type)!.add(handler) + return () => mockWsHandlers.get(type)?.delete(handler) + }, + status: () => 'connected', + send: jest.fn(), + }), + onAnyStatusChange: () => () => {}, + }, +})) + +function emit(type: string, msg: unknown) { + mockWsHandlers.get(type)?.forEach((h) => h(msg)) +} + +jest.mock('@/hooks/useSessionActions', () => ({ + useSessionActions: () => ({ resume: { mutate: jest.fn(), isPending: false, isError: false } }), +})) + +// Surface each message's first text block so the transcript is assertable. +jest.mock('@/components/conversation/MessageItem', () => ({ + MessageItem: ({ message }: { message: Message }) => { + const { Text: RNText } = jest.requireActual('react-native') + const textBlock = message.content.find((b: { type: string }) => b.type === 'text') as + | { type: 'text'; text: string } + | undefined + if (!textBlock) return null + return {textBlock.text} + }, +})) + +function histMsg(index: number, uuid: string, text: string): Message { + return { + id: `conv-1-${index}`, + uuid, + messageIndex: index, + role: index % 2 === 0 ? 'user' : 'assistant', + content: [{ type: 'text', text }], + timestamp: `2026-07-19T10:00:0${index}Z`, + is_sidechain: false, + parent_uuid: null, + } +} + +function makeConversation(messages: Message[]) { + return { + id: 'conv-1', + title: 'Live Convo', + projectPath: '/tmp/p', + messageCount: messages.length, + lastActivity: '2026-07-19T12:00:00Z', + resumable: true, + provider: 'claude-code' as const, + messages, + } +} + +// A raw Claude JSONL user line (content as a bare string, matching Claude Code). +function claudeUserLine(uuid: string, text: string): string { + return JSON.stringify({ + type: 'user', + uuid, + timestamp: '2026-07-19T12:00:00Z', + message: { role: 'user', content: text }, + }) +} + +function seedServer() { + useServersStore.setState({ + servers: { + srv1: { + id: 'srv1', + url: 'http://stub', + apiKey: 'k', + label: 'SRV1', + isConnected: true, + serverInfo: null, + connectionError: null, + }, + }, + activeServerIds: ['srv1'], + displayedServerIds: ['srv1'], + } as never) +} + +// Flush passive effects (and FlashList's cell-render timer) inside act. +async function settle(ms = 600) { + await act(async () => { + jest.advanceTimersByTime(ms) + }) +} + +describe('conversation live view — transcript merge (P2.2)', () => { + beforeEach(() => { + jest.useFakeTimers() + seedServer() + mockWsHandlers.clear() + mockTriggerDelta.mockClear() + mockConversationRef.current = makeConversation([histMsg(0, 'h0', 'hello historical')]) + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ id: 'conv-1', server: 'srv1' }) + }) + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + it('appends streamed conversation_events for the viewed conversation', async () => { + const root: RenderResult = await render(, { wrapper: createWrapper() }) + await settle() + expect(root.getByText('hello historical')).toBeTruthy() + + await act(async () => { + emit('conversation_events', { + type: 'conversation_events', + sessionId: 'conv-1', + lines: [claudeUserLine('L0', 'live streamed line')], + }) + }) + await settle() + + expect(root.getByText('live streamed line')).toBeTruthy() + }) + + it('ignores frames for a different conversation id', async () => { + const root: RenderResult = await render(, { wrapper: createWrapper() }) + await settle() + + await act(async () => { + emit('conversation_events', { + type: 'conversation_events', + sessionId: 'other-conv', + lines: [claudeUserLine('X', 'from another convo')], + }) + }) + await settle() + + expect(root.queryByText('from another convo')).toBeNull() + expect(root.getByText('hello historical')).toBeTruthy() + }) + + it('does not duplicate a line delivered over WS and again via REST history', async () => { + mockConversationRef.current = makeConversation([ + histMsg(0, 'h0', 'hello'), + histMsg(1, 'dup-uuid', 'echoed turn'), + ]) + const root: RenderResult = await render(, { wrapper: createWrapper() }) + await settle() + expect(root.getAllByText('echoed turn')).toHaveLength(1) + + await act(async () => { + emit('conversation_event', { + type: 'conversation_event', + sessionId: 'conv-1', + line: claudeUserLine('dup-uuid', 'echoed turn'), + }) + }) + await settle() + + expect(root.getAllByText('echoed turn')).toHaveLength(1) + }) + + it('is strictly read-only — renders no composer', async () => { + const root: RenderResult = await render(, { wrapper: createWrapper() }) + await settle() + + expect(root.queryByTestId('chat-message-input')).toBeNull() + expect(root.queryByTestId('chat-send-button')).toBeNull() + }) +}) + +describe('conversation live view — freshness triggers (P2.1)', () => { + let appStateListeners: ((s: string) => void)[] = [] + const fireAppState = (s: string) => appStateListeners.forEach((l) => l(s)) + + beforeEach(() => { + jest.useFakeTimers() + seedServer() + mockWsHandlers.clear() + mockTriggerDelta.mockClear() + mockConversationRef.current = makeConversation([histMsg(0, 'h0', 'hello historical')]) + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ id: 'conv-1', server: 'srv1' }) + appStateListeners = [] + Object.defineProperty(AppState, 'currentState', { value: 'active', configurable: true, writable: true }) + jest.spyOn(AppState, 'addEventListener').mockImplementation((_type, cb) => { + appStateListeners.push(cb as (s: string) => void) + return { remove: jest.fn() } as ReturnType + }) + }) + afterEach(() => { + jest.restoreAllMocks() + jest.clearAllTimers() + jest.useRealTimers() + }) + + it('polls the delta drain only while focused and foregrounded', async () => { + await render(, { wrapper: createWrapper() }) + // Flush passive effects so the focus-poll interval starts (currentState=active). + await act(async () => {}) + + await act(async () => { + jest.advanceTimersByTime(3000) + }) + expect(mockTriggerDelta).toHaveBeenCalledTimes(1) + await act(async () => { + jest.advanceTimersByTime(3000) + }) + expect(mockTriggerDelta).toHaveBeenCalledTimes(2) + + // Background: the interval stops. + fireAppState('background') + mockTriggerDelta.mockClear() + await act(async () => { + jest.advanceTimersByTime(9000) + }) + expect(mockTriggerDelta).not.toHaveBeenCalled() + + // Foreground again: it resumes. + fireAppState('active') + await act(async () => { + jest.advanceTimersByTime(3000) + }) + expect(mockTriggerDelta).toHaveBeenCalledTimes(1) + }) + + it('drains on a conversation_updated push for this conversation, and ignores others', async () => { + await render(, { wrapper: createWrapper() }) + await act(async () => {}) // flush effects → conversation_updated subscription registered + mockTriggerDelta.mockClear() // discard any mount-time interval tick + + await act(async () => { + emit('conversation_updated', { + type: 'conversation_updated', + conversationId: 'conv-1', + messageCount: 5, + lastActivity: '2026-07-19T12:01:00Z', + ownership: 'external', + }) + }) + expect(mockTriggerDelta).toHaveBeenCalledTimes(1) + + mockTriggerDelta.mockClear() + await act(async () => { + emit('conversation_updated', { + type: 'conversation_updated', + conversationId: 'other-conv', + messageCount: 9, + lastActivity: '2026-07-19T12:02:00Z', + ownership: 'external', + }) + }) + expect(mockTriggerDelta).not.toHaveBeenCalled() + }) + + it('pausing holds live updates; resuming drains once and re-enables them', async () => { + const root = await render(, { wrapper: createWrapper() }) + await act(async () => {}) + + // A growth push marks the session live → the pause control appears. + await act(async () => { + emit('conversation_updated', { + type: 'conversation_updated', + conversationId: 'conv-1', + messageCount: 2, + lastActivity: '2026-07-19T12:01:00Z', + ownership: 'external', + }) + }) + // Pause. + await act(async () => { + fireEvent.press(root.getByTestId('live-pause-toggle')) + }) + mockTriggerDelta.mockClear() + + // Paused: neither a growth push nor the focus poll drains. + await act(async () => { + emit('conversation_updated', { + type: 'conversation_updated', + conversationId: 'conv-1', + messageCount: 3, + lastActivity: '2026-07-19T12:02:00Z', + ownership: 'external', + }) + jest.advanceTimersByTime(9000) + }) + expect(mockTriggerDelta).not.toHaveBeenCalled() + + // Resume: catches up with exactly one drain. + await act(async () => { + fireEvent.press(root.getByTestId('live-pause-toggle')) + }) + expect(mockTriggerDelta).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/integration/conversation-resume-collision.test.tsx b/__tests__/integration/conversation-resume-collision.test.tsx new file mode 100644 index 00000000..f2143006 --- /dev/null +++ b/__tests__/integration/conversation-resume-collision.test.tsx @@ -0,0 +1,275 @@ +/** + * Resume-collision confirmation on the conversation detail screen (M/P0.1). + * + * The Resume button POSTs /api/sessions/resume. When the server soft-blocks + * with a 409 CONVERSATION_BUSY (the conversation may still be open in a terminal + * elsewhere), the screen must: + * - surface a confirm dialog naming what was detected, and NOT resume; + * - retry with { force: true } only if the user proceeds. + * A clean 200 resume proceeds straight to the live session, no dialog. + */ +import React from 'react' +import { Alert, type AlertButton } from 'react-native' +import { act, fireEvent, render, waitFor } from '@testing-library/react-native' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useLocalSearchParams, useRouter } from 'expo-router' +import ConversationDetailScreen from '@/app/conversation/[id]' +import { useServersStore } from '@/stores/servers' +import { ConversationBusyError } from '@/services/api-client' + +const CONV_ID = 'conv-resume' + +function mockMakeDetail(extra: Record = {}) { + const messages = Array.from({ length: 4 }, (_, i) => ({ + message_index: i, + uuid: `uuid-${i}`, + role: i % 2 === 0 ? 'user' : 'assistant', + timestamp: `2026-06-10T10:00:0${i}Z`, + text: `message ${i}`, + })) + return { + meta: { + id: CONV_ID, + project_name: 'Resume Test', + project_path: '/tmp/p', + last_updated_at: '2026-06-10T11:00:00Z', + message_count: 4, + resumable: true, + ...extra, + }, + messages, + message_pagination: { + total: 4, + before_index: -1, + from_index: 0, + has_more_older: false, + next_before_index: null, + }, + } +} + +const mockPost = jest.fn() + +jest.mock('@/services/api-client', () => { + const actual = jest.requireActual('@/services/api-client') + return { + ...actual, + createApiForServer: () => ({ + get: (path: string) => { + if (path.includes('/api/sessions/')) return Promise.reject(new Error('no session')) + return Promise.resolve(mockMakeDetail()) + }, + getWithMeta: () => Promise.resolve({ status: 200, etag: null, body: mockMakeDetail() }), + post: mockPost, + }), + } +}) + +function seedServer() { + useServersStore.setState({ + servers: { + srv1: { + id: 'srv1', + url: 'http://stub', + apiKey: 'k', + label: 'SRV1', + isConnected: true, + serverInfo: null, + connectionError: null, + }, + }, + activeServerIds: ['srv1'], + displayedServerIds: ['srv1'], + } as never) +} + +function wrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +let mockReplace: jest.Mock + +describe('conversation detail — resume collision', () => { + beforeEach(() => { + seedServer() + mockPost.mockReset() + mockReplace = jest.fn() + ;(useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + back: jest.fn(), + navigate: jest.fn(), + canGoBack: jest.fn(() => true), + }) + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ id: CONV_ID, server: 'srv1' }) + }) + + async function renderAndFindResume() { + const root = await render(, { wrapper: wrapper() }) + const btn = await waitFor(() => root.getByTestId('resume-button')) + return { root, btn } + } + + it('a 409 shows the confirm dialog and does NOT resume', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + mockPost.mockRejectedValue( + new ConversationBusyError('busy', { detectedBy: ['jsonl_mtime'], likelyOwner: 'external' }), + ) + + const { btn } = await renderAndFindResume() + await act(async () => { + fireEvent.press(btn) + }) + + await waitFor(() => expect(alertSpy).toHaveBeenCalledTimes(1)) + // One preflight POST, no force on it. + expect(mockPost).toHaveBeenCalledTimes(1) + expect(mockPost.mock.calls[0][1]).toEqual({ sessionId: CONV_ID }) + // Did NOT proceed to the live session. + expect(mockReplace).not.toHaveBeenCalled() + + alertSpy.mockRestore() + }) + + it('confirming the dialog retries with force: true and proceeds', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + mockPost + .mockRejectedValueOnce( + new ConversationBusyError('busy', { detectedBy: ['process_argv'], likelyOwner: 'external' }), + ) + .mockResolvedValueOnce({ + conversationId: CONV_ID, + sessionId: 'sess-forced', + projectId: 'proj-1', + projectPath: '/tmp/p', + status: 'resumed', + }) + + const { btn } = await renderAndFindResume() + await act(async () => { + fireEvent.press(btn) + }) + await waitFor(() => expect(alertSpy).toHaveBeenCalledTimes(1)) + + // Invoke the confirm button specifically — the dialog can also carry a + // destructive "Take over" option, so match on the absence of a style rather + // than "not cancel". + const buttons = (alertSpy.mock.calls[0][2] ?? []) as AlertButton[] + const confirm = buttons.find((b) => b.style === undefined) + await act(async () => { + confirm?.onPress?.() + }) + + await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2)) + expect(mockPost.mock.calls[1][1]).toEqual({ sessionId: CONV_ID, force: true }) + await waitFor(() => expect(mockReplace).toHaveBeenCalledTimes(1)) + const target = mockReplace.mock.calls[0][0] as string + expect(target).toContain('/session/sess-forced') + expect(target).toContain('resumedFromConversationId=' + CONV_ID) + + alertSpy.mockRestore() + }) + + it('a normal 200 resume proceeds with no dialog', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + mockPost.mockResolvedValue({ + conversationId: CONV_ID, + sessionId: 'sess-clean', + projectId: 'proj-1', + projectPath: '/tmp/p', + status: 'resumed', + }) + + const { btn } = await renderAndFindResume() + await act(async () => { + fireEvent.press(btn) + }) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledTimes(1)) + expect(mockPost).toHaveBeenCalledTimes(1) + expect(mockPost.mock.calls[0][1]).toEqual({ sessionId: CONV_ID }) + const target = mockReplace.mock.calls[0][0] as string + expect(target).toContain('/session/sess-clean') + expect(alertSpy).not.toHaveBeenCalled() + + alertSpy.mockRestore() + }) + + // Take-over is the SAFE destructive path (server stops the old process and + // waits before spawning), offered at the moment of conflict instead of being + // the primary button on a screen the user lands on by tapping a session. + it('offers "Take over" only when a real process was matched (likelyOwner external)', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + mockPost.mockRejectedValue( + new ConversationBusyError('busy', { detectedBy: ['process_argv'], likelyOwner: 'external' }), + ) + + const { btn } = await renderAndFindResume() + await act(async () => { + fireEvent.press(btn) + }) + + await waitFor(() => expect(alertSpy).toHaveBeenCalledTimes(1)) + const buttons = (alertSpy.mock.calls[0][2] ?? []) as AlertButton[] + expect(buttons.some((b) => b.style === 'destructive')).toBe(true) + + alertSpy.mockRestore() + }) + + it('omits "Take over" when only an mtime hit was seen (nothing to adopt)', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + mockPost.mockRejectedValue( + new ConversationBusyError('busy', { detectedBy: ['jsonl_mtime'], likelyOwner: 'unknown' }), + ) + + const { btn } = await renderAndFindResume() + await act(async () => { + fireEvent.press(btn) + }) + + await waitFor(() => expect(alertSpy).toHaveBeenCalledTimes(1)) + const buttons = (alertSpy.mock.calls[0][2] ?? []) as AlertButton[] + expect(buttons.some((b) => b.style === 'destructive')).toBe(false) + // Cancel + "Resume anyway" only. + expect(buttons).toHaveLength(2) + + alertSpy.mockRestore() + }) + + it('pressing "Take over" adopts the session instead of force-resuming', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + mockPost + .mockRejectedValueOnce( + new ConversationBusyError('busy', { detectedBy: ['process_argv'], likelyOwner: 'external' }), + ) + .mockResolvedValueOnce({ sessionId: 'sess-adopted' }) + + const { btn } = await renderAndFindResume() + await act(async () => { + fireEvent.press(btn) + }) + await waitFor(() => expect(alertSpy).toHaveBeenCalledTimes(1)) + + const buttons = (alertSpy.mock.calls[0][2] ?? []) as AlertButton[] + const takeOver = buttons.find((b) => b.style === 'destructive') + await act(async () => { + takeOver?.onPress?.() + }) + + // Hits the adopt endpoint — never a forced resume (which would leave two + // agents writing one transcript). + await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2)) + expect(mockPost.mock.calls[1][0]).toBe(`/api/sessions/${CONV_ID}/adopt`) + expect(mockPost.mock.calls.some(([, body]) => (body as { force?: boolean })?.force)).toBe(false) + + await waitFor(() => expect(mockReplace).toHaveBeenCalledTimes(1)) + expect(mockReplace.mock.calls[0][0] as string).toContain('/session/sess-adopted') + + alertSpy.mockRestore() + }) +}) diff --git a/__tests__/integration/conversation-search-anchor.test.tsx b/__tests__/integration/conversation-search-anchor.test.tsx index 7ca602f0..0b913efc 100644 --- a/__tests__/integration/conversation-search-anchor.test.tsx +++ b/__tests__/integration/conversation-search-anchor.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react' import { act, fireEvent, render, waitFor, within } from '@testing-library/react-native' -import { useLocalSearchParams } from 'expo-router' +import { useLocalSearchParams, useRouter } from 'expo-router' import ConversationDetailScreen from '@/app/conversation/[id]' import { useServersStore } from '@/stores/servers' import { createWrapper } from '@/test-utils' @@ -119,6 +119,14 @@ beforeEach(() => { mockAnchoredResponder = null mockRequestedPaths.length = 0 mockQueryCalls.length = 0 + ;(useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + navigate: jest.fn(), + setParams: jest.fn(), + canGoBack: jest.fn(() => true), + }) }) afterEach(() => { @@ -377,4 +385,70 @@ describe('conversation detail — search-anchored navigation', () => { }) expect(mockRequestedPaths.some((p) => p.includes('/search-target'))).toBe(false) }) + + it('submits in-chat search from the header bar and scopes to this conversation', async () => { + const setParams = jest.fn() + ;(useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + navigate: jest.fn(), + setParams, + canGoBack: jest.fn(() => true), + }) + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ id: 'conv-anchor', server: 'srv1' }) + mockTailResponder = () => makeDetail(0, 10, 10) + + const { getByTestId, queryByTestId } = await render(, { + wrapper: createWrapper(), + }) + await flushQueries() + + await waitFor(() => { + expect(getByTestId('conversation-search-btn')).toBeTruthy() + }) + expect(queryByTestId('conversation-search-input')).toBeNull() + + await act(async () => { + fireEvent.press(getByTestId('conversation-search-btn')) + }) + const input = getByTestId('conversation-search-input') + await act(async () => { + fireEvent.changeText(input, ' needle ') + }) + await act(async () => { + fireEvent(input, 'submitEditing') + }) + + expect(setParams).toHaveBeenCalledWith({ search: 'needle', anchor_index: '' }) + // Hub-wide /api/search must not be used for in-chat search. + expect(mockRequestedPaths.some((p) => p.includes('/api/search'))).toBe(false) + }) + + it('auto-opens the in-chat search bar when navigation already carries ?search=', async () => { + ;(useLocalSearchParams as jest.Mock).mockReturnValue({ + id: 'conv-anchor', + server: 'srv1', + search: 'needle', + }) + mockSearchTargetResponder = () => ({ + query: 'needle', + message_index: 150, + uuid: 'uuid-150', + snippet: 'a needle appears', + match_indexes: [150], + total_matches: 1, + }) + mockAnchoredResponder = () => + makeDetail(90, 120, 300, { anchor_index: 150, has_more_newer: true, next_after_index: 210 }) + + const { getByTestId } = await render(, { wrapper: createWrapper() }) + await flushQueries() + + await waitFor(() => { + expect(getByTestId('search-match-nav')).toBeTruthy() + }) + expect(getByTestId('conversation-search-input')).toBeTruthy() + expect(getByTestId('conversation-search-input').props.value).toBe('needle') + }) }) diff --git a/__tests__/integration/hooks/useClaudeFlags.test.tsx b/__tests__/integration/hooks/useClaudeFlags.test.tsx new file mode 100644 index 00000000..97af4cb1 --- /dev/null +++ b/__tests__/integration/hooks/useClaudeFlags.test.tsx @@ -0,0 +1,107 @@ +/** + * useClaudeFlags / useUpdateClaudeFlags. + * + * Guards the two contracts that keep this feature safe against older servers and + * against showing a stale security setting: + * - a 404 resolves to null (server predates the feature) rather than throwing + * - a successful write seeds the cache from the SERVER's normalised response + */ +import React from 'react' +import { renderHook, waitFor } from '@testing-library/react-native' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useClaudeFlags, useUpdateClaudeFlags } from '@/hooks/useClaudeFlags' +import { NotFoundError } from '@/services/api-client' + +const mockGet = jest.fn() +const mockPut = jest.fn() + +// Mock the exported helpers themselves, not createApiForServer: getClaudeFlags +// lives in the same module and closes over the real createApiForServer, so +// swapping that alone would leave the helpers calling the real client. +jest.mock('@/services/api-client', () => { + const actual = jest.requireActual('@/services/api-client') + return { + ...actual, + getClaudeFlags: (serverId: string) => mockGet(serverId), + updateClaudeFlags: (serverId: string, values: unknown, extraArgs?: string) => + mockPut(serverId, values, extraArgs), + } +}) + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return {children} +} + +const CONFIG = { + registry: [{ id: 'maxBudgetUsd', flag: '--max-budget-usd', valueType: 'string', risk: 'low' }], + values: { maxBudgetUsd: '5' }, + extraArgs: null, + persisted: true, +} + +beforeEach(() => { + mockGet.mockReset() + mockPut.mockReset() +}) + +describe('useClaudeFlags', () => { + it('returns the config on success', async () => { + mockGet.mockResolvedValue(CONFIG) + const { result } = await renderHook(() => useClaudeFlags('srv1'), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(CONFIG) + }) + + // An older streamer has no /api/config/claude-flags. getClaudeFlags maps that + // 404 to null so the UI reads it as "feature absent", not as an error banner. + it('resolves to null when the server predates the feature', async () => { + mockGet.mockResolvedValue(null) + const { result } = await renderHook(() => useClaudeFlags('srv1'), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + }) + + it('surfaces other errors', async () => { + mockGet.mockRejectedValue(new Error('boom')) + const { result } = await renderHook(() => useClaudeFlags('srv1'), { wrapper }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) + +describe('useUpdateClaudeFlags', () => { + it('sends values and extraArgs', async () => { + mockPut.mockResolvedValue(CONFIG) + const { result } = await renderHook(() => useUpdateClaudeFlags('srv1'), { wrapper }) + + result.current.mutate({ values: { maxBudgetUsd: '5' }, extraArgs: '--bare' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockPut).toHaveBeenCalledWith('srv1', { maxBudgetUsd: '5' }, '--bare') + }) + + it('omits extraArgs when empty', async () => { + mockPut.mockResolvedValue(CONFIG) + const { result } = await renderHook(() => useUpdateClaudeFlags('srv1'), { wrapper }) + + result.current.mutate({ values: {} }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockPut).toHaveBeenCalledWith('srv1', {}, undefined) + }) + + it('surfaces a write failure instead of silently rolling back', async () => { + mockPut.mockRejectedValue(new Error('forbidden')) + const { result } = await renderHook(() => useUpdateClaudeFlags('srv1'), { wrapper }) + + result.current.mutate({ values: {} }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(result.current.error?.message).toBe('forbidden') + }) +}) diff --git a/__tests__/integration/services/cacheAlertResolveFlow.test.tsx b/__tests__/integration/services/cacheAlertResolveFlow.test.tsx new file mode 100644 index 00000000..6d9d594f --- /dev/null +++ b/__tests__/integration/services/cacheAlertResolveFlow.test.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { fireEvent, waitFor } from '@testing-library/react-native' +import { getCacheAlert } from '@/services/api-client' +import { useServersStore } from '@/stores/servers' +import { CacheAlertModal } from '@/components/servers/CacheAlertModal' +import { CacheAlertBanner } from '@/components/servers/CacheAlertBanner' +import { renderWithI18n } from '@/test-utils/render' + +// End-to-end resolve flow against a mocked fetch transport (not a mocked +// api-client), so getCacheAlert's 404-as-feature-detection and +// resolveCacheAlert's 409-as-conflict paths are exercised for real. +const mockFetch = jest.fn() +global.fetch = mockFetch + +const SERVER_ID = 'srv_test1' + +function seedServer() { + useServersStore.setState({ + servers: { + [SERVER_ID]: { + id: SERVER_ID, + url: 'http://test.local', + label: 'My Server', + apiKey: 'k', + isConnected: true, + serverInfo: null, + connectionError: null, + }, + }, + activeServerIds: [SERVER_ID], + displayedServerIds: [SERVER_ID], + isLoading: false, + cacheAlert: {}, + }) +} + +beforeEach(() => { + jest.clearAllMocks() + seedServer() +}) + +describe('cache alert resolve flow (integration)', () => { + it('getCacheAlert against an old server (404) returns null and the feature stays fully hidden', async () => { + mockFetch.mockResolvedValueOnce({ status: 404, ok: false, json: async () => ({}) }) + + const pending = await getCacheAlert(SERVER_ID) + expect(pending).toBeNull() + + useServersStore.getState().setCacheAlert(SERVER_ID, pending) + const { toJSON: bannerJson } = await renderWithI18n() + const { toJSON: modalJson } = await renderWithI18n( + + ) + expect(bannerJson()).toBeNull() + expect(modalJson()).toBeNull() + }) + + it('normal success: fetches a pending alert, resolves it, and reports the backup path', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ + pending: { + fingerprint: 'fp1', + severity: 'high', + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 2, + totalRows: 10, + missing: [{ id: 'a', filePath: '/a.jsonl', title: 'Session A', tailed: false }], + }, + }), + }) + const pending = await getCacheAlert(SERVER_ID) + useServersStore.getState().setCacheAlert(SERVER_ID, pending) + + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ ok: true, action: 'prune_all', pruned: 2, backupPath: '/backup/x' }), + }) + + const onResolved = jest.fn() + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune All')) + await fireEvent.press(await findByText('Proceed')) + + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('/backup/x')) + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://test.local/api/cache/alert/resolve', + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('409 fingerprint_mismatch: refetches state and re-renders instead of erroring', async () => { + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ + pending: { + fingerprint: 'fp1', + severity: 'high', + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 2, + totalRows: 10, + }, + }), + }) + const pending = await getCacheAlert(SERVER_ID) + useServersStore.getState().setCacheAlert(SERVER_ID, pending) + + // resolve → 409 + mockFetch.mockResolvedValueOnce({ + status: 409, + ok: false, + json: async () => ({ error: 'fingerprint_mismatch', currentFingerprint: 'fp2' }), + }) + // subsequent refetch of GET /api/cache/alert returns the new pending state + mockFetch.mockResolvedValueOnce({ + status: 200, + ok: true, + json: async () => ({ + pending: { + fingerprint: 'fp2', + severity: 'high', + detectedAt: '2026-07-18T00:01:00.000Z', + missingCount: 1, + totalRows: 10, + }, + }), + }) + + const onResolved = jest.fn() + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune All')) + await fireEvent.press(await findByText('Proceed')) + + await waitFor(() => + expect(useServersStore.getState().cacheAlert[SERVER_ID]?.fingerprint).toBe('fp2'), + ) + expect(onResolved).not.toHaveBeenCalled() + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('propagates a real server error from getCacheAlert instead of silently hiding the feature', async () => { + mockFetch.mockResolvedValueOnce({ status: 500, ok: false, json: async () => ({ error: 'boom' }) }) + await expect(getCacheAlert(SERVER_ID)).rejects.toThrow() + }) +}) diff --git a/__tests__/unit/components/RenderErrorBoundary.test.tsx b/__tests__/unit/components/RenderErrorBoundary.test.tsx new file mode 100644 index 00000000..149a5011 --- /dev/null +++ b/__tests__/unit/components/RenderErrorBoundary.test.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Text } from 'react-native' +import { render, fireEvent } from '@testing-library/react-native' +import { RenderErrorBoundary } from '@/components/RenderErrorBoundary' + +jest.mock('@/services/sentry', () => ({ + captureHandledError: jest.fn(), +})) + +function Boom(): React.ReactElement { + throw new Error('boom-render') +} + +describe('RenderErrorBoundary', () => { + let consoleSpy: jest.SpyInstance + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + consoleSpy.mockRestore() + }) + + it('contains a child render error and shows fallback', async () => { + const { getByTestId, getByText } = await render( + + + , + ) + expect(getByTestId('render-error-fallback')).toBeTruthy() + expect(getByText(/raw line text/)).toBeTruthy() + }) + + it('retries after a contained failure', async () => { + let shouldThrow = true + function Flaky() { + if (shouldThrow) throw new Error('once') + return recovered + } + const { getByTestId, getByText, rerender } = await render( + + + , + ) + expect(getByTestId('render-error-fallback')).toBeTruthy() + shouldThrow = false + fireEvent.press(getByText('Retry')) + await rerender( + + + , + ) + expect(getByText('recovered')).toBeTruthy() + }) +}) diff --git a/__tests__/unit/components/conversation/ThinkingBubble.skeleton.test.tsx b/__tests__/unit/components/conversation/ThinkingBubble.skeleton.test.tsx new file mode 100644 index 00000000..1238cad3 --- /dev/null +++ b/__tests__/unit/components/conversation/ThinkingBubble.skeleton.test.tsx @@ -0,0 +1,48 @@ +import { render } from '@testing-library/react-native' +import React from 'react' +import { ThinkingBubble } from '@/components/conversation/ThinkingBubble' +import type { QuestionBlock } from '@/utils/parseQuestionBlock' + +// isStreaming goes false after 1500ms of PTY silence, but Claude routinely +// thinks for 30s+ without emitting anything. That window used to render a +// frozen block of stale terminal text with nothing moving, which reads as a +// dead session — the skeleton is what keeps the in-progress turn legible. +describe('ThinkingBubble progress skeleton', () => { + it('shows the skeleton when the agent is working but the PTY has gone quiet', async () => { + const { queryByTestId } = await render( + , + ) + expect(queryByTestId('thinking-skeleton')).toBeTruthy() + }) + + it('shows dots instead of the skeleton while output is actively streaming', async () => { + const { queryByTestId } = await render( + , + ) + expect(queryByTestId('thinking-skeleton')).toBeNull() + }) + + it('shows dots (not the skeleton) before any output has arrived', async () => { + const { queryByTestId } = await render() + expect(queryByTestId('thinking-skeleton')).toBeNull() + }) + + it('shows neither once a question card takes over the bubble', async () => { + const question: QuestionBlock = { + source: 'structured', + toolUseId: 't1', + questions: [ + { + question: 'Proceed?', + header: 'H', + multiSelect: false, + options: [{ label: 'Yes', description: 'y' }], + }, + ], + } + const { queryByTestId } = await render( + , + ) + expect(queryByTestId('thinking-skeleton')).toBeNull() + }) +}) diff --git a/__tests__/unit/components/servers/CacheAlertBanner.test.tsx b/__tests__/unit/components/servers/CacheAlertBanner.test.tsx new file mode 100644 index 00000000..a90f33d2 --- /dev/null +++ b/__tests__/unit/components/servers/CacheAlertBanner.test.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react-native' +import { CacheAlertBanner } from '@/components/servers/CacheAlertBanner' +import { useServersStore } from '@/stores/servers' +import { renderWithI18n } from '@/test-utils/render' + +function seedServer(overrides: Partial = {}) { + const server = { + id: 'srv_test1', + url: 'http://192.168.1.10:7070', + label: 'My Server', + apiKey: 'key-abc', + isConnected: true, + serverInfo: null, + connectionError: null, + ...overrides, + } + useServersStore.setState({ + servers: { [server.id]: server }, + activeServerIds: [server.id], + displayedServerIds: [server.id], + isLoading: false, + }) + return server +} + +beforeEach(() => { + useServersStore.setState({ + servers: {}, + activeServerIds: [], + displayedServerIds: [], + isLoading: false, + cacheAlert: {}, + }) +}) + +describe('CacheAlertBanner', () => { + it('renders nothing when there is no cache alert', async () => { + seedServer() + const { toJSON } = await renderWithI18n() + expect(toJSON()).toBeNull() + }) + + it('renders nothing for a high-severity alert (handled by the modal, not the banner)', async () => { + const server = seedServer() + useServersStore.getState().setCacheAlert(server.id, { + fingerprint: 'fp1', + severity: 'high', + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 3, + totalRows: 10, + }) + const { toJSON } = await renderWithI18n() + expect(toJSON()).toBeNull() + }) + + it('renders the banner for a low-severity alert with the missing count and server label', async () => { + const server = seedServer() + useServersStore.getState().setCacheAlert(server.id, { + fingerprint: 'fp1', + severity: 'low', + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 3, + totalRows: 10, + }) + const { findByText } = await renderWithI18n() + expect(await findByText(/3 conversation histories are missing on My Server/)).toBeTruthy() + }) + + it('calls onPress when tapped', async () => { + const server = seedServer() + useServersStore.getState().setCacheAlert(server.id, { + fingerprint: 'fp1', + severity: 'low', + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 1, + totalRows: 10, + }) + const onPress = jest.fn() + const { findByRole } = await renderWithI18n() + const banner = await findByRole('button') + fireEvent.press(banner) + expect(onPress).toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/components/servers/CacheAlertModal.test.tsx b/__tests__/unit/components/servers/CacheAlertModal.test.tsx new file mode 100644 index 00000000..53ed509a --- /dev/null +++ b/__tests__/unit/components/servers/CacheAlertModal.test.tsx @@ -0,0 +1,274 @@ +import React from 'react' +import { fireEvent, waitFor } from '@testing-library/react-native' +import { CacheAlertModal } from '@/components/servers/CacheAlertModal' +import { useServersStore } from '@/stores/servers' +import { resolveCacheAlert, getCacheAlert } from '@/services/api-client' +import { renderWithI18n } from '@/test-utils/render' +import { queryClient } from '@/services/query-client' +import type { MultiConversation, MultiSession } from '@/types/api' +import { useSessionsStore } from '@/stores/sessions' + +jest.mock('@/services/api-client', () => ({ + resolveCacheAlert: jest.fn(), + getCacheAlert: jest.fn(), +})) + +const mockResolve = resolveCacheAlert as jest.Mock +const mockGetAlert = getCacheAlert as jest.Mock + +const SERVER_ID = 'srv_test1' + +function seedAlert(overrides: Partial = {}) { + const alert = { + fingerprint: 'fp1', + severity: 'high' as const, + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 2, + totalRows: 10, + missing: [ + { id: 'a', filePath: '/a.jsonl', title: 'Session A', tailed: false }, + { id: 'b', filePath: '/b.jsonl', title: 'Session B', tailed: false }, + ], + ...overrides, + } + useServersStore.setState({ + servers: { + [SERVER_ID]: { + id: SERVER_ID, + url: 'http://test.local', + label: 'My Server', + apiKey: 'k', + isConnected: true, + serverInfo: null, + connectionError: null, + }, + }, + activeServerIds: [SERVER_ID], + displayedServerIds: [SERVER_ID], + isLoading: false, + cacheAlert: { [SERVER_ID]: alert }, + }) + return alert +} + +beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + useSessionsStore.setState({ promptQueues: {} }) + useServersStore.setState({ + servers: {}, + activeServerIds: [], + displayedServerIds: [], + isLoading: false, + cacheAlert: {}, + }) +}) + +describe('CacheAlertModal', () => { + it('renders nothing when there is no pending alert for the server', async () => { + const { toJSON } = await renderWithI18n( + + ) + expect(toJSON()).toBeNull() + }) + + it('shows the backup hint for high severity', async () => { + seedAlert({ severity: 'high' }) + const { findByText } = await renderWithI18n( + + ) + expect(await findByText(/Time Machine/)).toBeTruthy() + }, 15_000) + + it('does not show the backup hint for low severity', async () => { + seedAlert({ severity: 'low' }) + const { queryByText } = await renderWithI18n( + + ) + expect(queryByText(/Time Machine/)).toBeNull() + }) + + it('requires a confirm step before calling resolveCacheAlert for prune_all', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: true, action: 'prune_all', pruned: 2 }) + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune All')) + expect(mockResolve).not.toHaveBeenCalled() + expect(await findByText('Are you sure?')).toBeTruthy() + + await fireEvent.press(await findByText('Proceed')) + await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(SERVER_ID, { + fingerprint: 'fp1', + action: 'prune_all', + ids: undefined, + })) + }) + + it('does not require a confirm step for ignore', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: true, action: 'ignore' }) + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Ignore')) + await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(SERVER_ID, { + fingerprint: 'fp1', + action: 'ignore', + ids: undefined, + })) + }) + + it('blocks prune_selected with no selection and shows an error', async () => { + seedAlert() + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune Selected')) + expect(await findByText('Select at least one conversation to prune.')).toBeTruthy() + expect(mockResolve).not.toHaveBeenCalled() + }) + + it('sends selected ids for prune_selected after confirming', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: true, action: 'prune_selected', pruned: 1 }) + const targetConversation = { id: 'target-conv', serverId: SERVER_ID } as MultiConversation + queryClient.setQueryData(['conversations-eager', undefined, 0, SERVER_ID], [targetConversation]) + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Session A')) + await fireEvent.press(await findByText('Prune Selected')) + await fireEvent.press(await findByText('Proceed')) + await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(SERVER_ID, { + fingerprint: 'fp1', + action: 'prune_selected', + ids: ['a'], + })) + expect(queryClient.getQueryData(['conversations-eager', undefined, 0, SERVER_ID])).toEqual([]) + }) + + it('clears server state after reset_rescan starts', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: true, action: 'reset_rescan' }) + const targetSession = { id: 'target-session', serverId: SERVER_ID } as MultiSession + queryClient.setQueryData(['sessions-eager', 'lastActivityAt', 'desc', '', SERVER_ID], [targetSession]) + + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Reset & Rescan')) + await fireEvent.press(await findByText('Proceed')) + + await waitFor(() => expect(mockResolve).toHaveBeenCalled()) + expect(queryClient.getQueryData(['sessions-eager', 'lastActivityAt', 'desc', '', SERVER_ID])).toEqual([]) + }) + + it('calls onResolved with the backup path on success', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: true, action: 'prune_all', pruned: 2, backupPath: '/backup/x' }) + const onResolved = jest.fn() + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune All')) + await fireEvent.press(await findByText('Proceed')) + await waitFor(() => expect(onResolved).toHaveBeenCalledWith('/backup/x')) + }) + + it('waits for destructive success, clears only that server, and refetches the lists', async () => { + seedAlert() + const otherConversation = { id: 'other-conv', serverId: 'srv_other' } as MultiConversation + const targetConversation = { id: 'target-conv', serverId: SERVER_ID } as MultiConversation + const otherSession = { id: 'other-session', serverId: 'srv_other' } as MultiSession + const targetSession = { id: 'target-session', serverId: SERVER_ID } as MultiSession + queryClient.setQueryData(['conversations-eager', undefined, 0, SERVER_ID], [targetConversation, otherConversation]) + queryClient.setQueryData(['sessions-eager', 'lastActivityAt', 'desc', '', SERVER_ID], [targetSession, otherSession]) + queryClient.setQueryData(['conversation', SERVER_ID, 'target-conv'], { stale: true }) + queryClient.setQueryData(['session', SERVER_ID, 'target-session'], { stale: true }) + queryClient.setQueryData(['conversations', 'search', 'term', SERVER_ID], [targetConversation, otherConversation]) + useSessionsStore.setState({ + promptQueues: { + [`${SERVER_ID}::target-session`]: [], + 'srv_other::other-session': [], + }, + }) + const invalidate = jest.spyOn(queryClient, 'invalidateQueries') + mockResolve.mockImplementation(async () => { + expect(queryClient.getQueryData(['conversations-eager', undefined, 0, SERVER_ID])).toEqual([ + targetConversation, + otherConversation, + ]) + expect(invalidate).not.toHaveBeenCalled() + return { ok: true, action: 'prune_all', pruned: 1 } + }) + + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune All')) + await fireEvent.press(await findByText('Proceed')) + + await waitFor(() => { + expect(queryClient.getQueryData(['conversations-eager', undefined, 0, SERVER_ID])).toEqual([ + otherConversation, + ]) + }) + expect(queryClient.getQueryData(['sessions-eager', 'lastActivityAt', 'desc', '', SERVER_ID])).toEqual([ + otherSession, + ]) + expect(queryClient.getQueryData(['conversation', SERVER_ID, 'target-conv'])).toBeUndefined() + expect(queryClient.getQueryData(['session', SERVER_ID, 'target-session'])).toBeUndefined() + expect(queryClient.getQueryData(['conversations', 'search', 'term', SERVER_ID])).toEqual([ + otherConversation, + ]) + expect(useSessionsStore.getState().promptQueues).toEqual({ + 'srv_other::other-session': [], + }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['conversations-eager'] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['conversations'] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sessions-eager'] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sessions'] }) + invalidate.mockRestore() + }) + + it('does not clear list state after ignore succeeds', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: true, action: 'ignore' }) + const targetConversation = { id: 'target-conv', serverId: SERVER_ID } as MultiConversation + queryClient.setQueryData(['conversations-eager', undefined, 0, SERVER_ID], [targetConversation]) + + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Ignore')) + + await waitFor(() => expect(mockResolve).toHaveBeenCalled()) + expect(queryClient.getQueryData(['conversations-eager', undefined, 0, SERVER_ID])).toEqual([ + targetConversation, + ]) + }) + + it('on 409 conflict, refetches the alert instead of calling onResolved', async () => { + seedAlert() + mockResolve.mockResolvedValue({ ok: false, conflict: true, currentFingerprint: 'fp2' }) + mockGetAlert.mockResolvedValue({ + fingerprint: 'fp2', + severity: 'high', + detectedAt: '2026-07-18T00:01:00.000Z', + missingCount: 1, + totalRows: 10, + }) + const onResolved = jest.fn() + const { findByText } = await renderWithI18n( + + ) + await fireEvent.press(await findByText('Prune All')) + await fireEvent.press(await findByText('Proceed')) + + await waitFor(() => expect(mockGetAlert).toHaveBeenCalledWith(SERVER_ID)) + expect(onResolved).not.toHaveBeenCalled() + expect(useServersStore.getState().cacheAlert[SERVER_ID]?.fingerprint).toBe('fp2') + }) +}) diff --git a/__tests__/unit/components/servers/ServerIndexingBanner.test.tsx b/__tests__/unit/components/servers/ServerIndexingBanner.test.tsx new file mode 100644 index 00000000..6540c60e --- /dev/null +++ b/__tests__/unit/components/servers/ServerIndexingBanner.test.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { ServerIndexingBanner } from '@/components/servers/ServerIndexingBanner' +import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' +import { useServersStore } from '@/stores/servers' +import { renderWithI18n } from '@/test-utils/render' + +const SERVER_ID = 'srv-test' + +beforeEach(() => { + useServersStore.setState({ + servers: { + [SERVER_ID]: { + id: SERVER_ID, + url: 'http://test.local', + apiKey: 'k', + isConnected: true, + serverInfo: null, + connectionError: null, + }, + }, + activeServerIds: [SERVER_ID], + displayedServerIds: [SERVER_ID], + scanProgress: {}, + }) + useServerFetchStatusStore.getState().reset() +}) + +describe('ServerIndexingBanner', () => { + it('stays hidden without an explicit warm-up response', async () => { + const { toJSON } = await renderWithI18n() + expect(toJSON()).toBeNull() + }) + + it('renders after the server returns its warm-up status', async () => { + useServerFetchStatusStore.setState({ + statuses: { + [SERVER_ID]: { + status: 'warming_up', + warmupState: 'startup', + lastCheckedAt: Date.now(), + }, + }, + }) + + const { findByText } = await renderWithI18n() + expect(await findByText('Scanning and indexing conversations…')).toBeTruthy() + }) + + it('renders progress for every warming server', async () => { + const secondServerId = 'srv-second' + const firstServer = useServersStore.getState().servers[SERVER_ID] + + useServersStore.setState({ + servers: { + [SERVER_ID]: { ...firstServer, label: 'Alpha Server' }, + [secondServerId]: { + id: secondServerId, + label: 'Beta Server', + url: 'http://second.test.local', + apiKey: 'k', + isConnected: true, + serverInfo: null, + connectionError: null, + }, + }, + activeServerIds: [SERVER_ID, secondServerId], + displayedServerIds: [SERVER_ID, secondServerId], + scanProgress: { + [SERVER_ID]: { scanned: 12, total: 100 }, + [secondServerId]: { scanned: 34, total: 200 }, + }, + }) + useServerFetchStatusStore.setState({ + statuses: { + [SERVER_ID]: { + status: 'warming_up', + warmupState: 'startup', + lastCheckedAt: Date.now(), + }, + [secondServerId]: { + status: 'warming_up', + warmupState: 'cache_reset', + lastCheckedAt: Date.now(), + }, + }, + }) + + const { findByText } = await renderWithI18n() + expect(await findByText('Alpha Server')).toBeTruthy() + expect(await findByText('12 / 100 files')).toBeTruthy() + expect(await findByText('Beta Server')).toBeTruthy() + expect(await findByText('34 / 200 files')).toBeTruthy() + }) + + it('stays hidden for ordinary fetch errors', async () => { + useServerFetchStatusStore.getState().recordFailure(SERVER_ID, new Error('unreachable')) + const { toJSON } = await renderWithI18n() + expect(toJSON()).toBeNull() + }) +}) diff --git a/__tests__/unit/components/sessions/hub/useServerGroups.test.tsx b/__tests__/unit/components/sessions/hub/useServerGroups.test.tsx new file mode 100644 index 00000000..7c7c87f9 --- /dev/null +++ b/__tests__/unit/components/sessions/hub/useServerGroups.test.tsx @@ -0,0 +1,54 @@ +import { useServerGroups } from '@/components/sessions/hub/useServerGroups' +import type { ProjectGroup } from '@/components/sessions/hub/useProjectGroups' +import type { MultiConversation } from '@/types/api' +import { renderHook } from '@testing-library/react-native' + +function makeGroup( + overrides: Partial & Pick, +): ProjectGroup { + return { + projectName: 'proj', + sessions: [], + conversations: [], + latestActivityMs: 0, + earliestStartMs: 0, + ...overrides, + } +} + +describe('useServerGroups', () => { + it('keeps empty/offline servers visible as zero-count sections', async () => { + const conversation = { + id: 'c1', + serverId: 'srv-A', + projectPath: '/tmp/a', + projectName: 'a', + title: 'c1', + messageCount: 1, + lastActivity: '2026-07-01T00:00:00.000Z', + } as MultiConversation + + const groups = [ + makeGroup({ + projectId: '/tmp/a', + projectPath: '/tmp/a', + conversations: [conversation], + }), + ] + + const { result } = await renderHook(() => + useServerGroups(groups, ['srv-A', 'srv-B'], { + 'srv-A': 'Alpha', + 'srv-B': 'Beta', + }), + ) + + expect(result.current).toHaveLength(2) + expect(result.current[0]).toMatchObject({ serverId: 'srv-A', totalCount: 1 }) + expect(result.current[1]).toMatchObject({ + serverId: 'srv-B', + totalCount: 0, + groups: [], + }) + }) +}) diff --git a/__tests__/unit/constants/a11y.test.ts b/__tests__/unit/constants/a11y.test.ts new file mode 100644 index 00000000..b35346ac --- /dev/null +++ b/__tests__/unit/constants/a11y.test.ts @@ -0,0 +1,16 @@ +import { + MAX_FONT_SIZE_MULTIPLIER_MONO, + MAX_FONT_SIZE_MULTIPLIER_UI, + MIN_TOUCH_TARGET, +} from '@/constants/a11y' + +describe('a11y constants', () => { + it('keeps touch targets at least 44pt', () => { + expect(MIN_TOUCH_TARGET).toBeGreaterThanOrEqual(44) + }) + + it('caps mono Dynamic Type below general UI', () => { + expect(MAX_FONT_SIZE_MULTIPLIER_MONO).toBeLessThan(MAX_FONT_SIZE_MULTIPLIER_UI) + expect(MAX_FONT_SIZE_MULTIPLIER_MONO).toBeGreaterThan(1) + }) +}) diff --git a/__tests__/unit/hooks/reuseMessageIdentities.test.ts b/__tests__/unit/hooks/reuseMessageIdentities.test.ts new file mode 100644 index 00000000..6e3f1219 --- /dev/null +++ b/__tests__/unit/hooks/reuseMessageIdentities.test.ts @@ -0,0 +1,45 @@ +import { reuseMessageIdentities } from '@/hooks/useConversations' +import type { Message } from '@/types/api' + +function msg(id: string, text: string): Message { + return { + id, + uuid: id, + role: 'user', + content: [{ type: 'text', text }], + timestamp: '2026-07-23T00:00:00Z', + is_sidechain: false, + parent_uuid: null, + } +} + +describe('reuseMessageIdentities', () => { + it('reuses prior objects for unchanged ids so references stay stable', () => { + const prev = [msg('a', 'hi'), msg('b', 'there')] + // Fresh rebuild: same content, brand-new object identities (what a drain produces). + const next = [msg('a', 'hi'), msg('b', 'there')] + const out = reuseMessageIdentities(prev, next) + expect(out[0]).toBe(prev[0]) + expect(out[1]).toBe(prev[1]) + }) + + it('keeps the new object when content changed', () => { + const prev = [msg('a', 'hi')] + const next = [msg('a', 'edited')] + const out = reuseMessageIdentities(prev, next) + expect(out[0]).toBe(next[0]) + }) + + it('reuses existing rows and adds appended ones (the live-append case)', () => { + const prev = [msg('a', 'hi')] + const next = [msg('a', 'hi'), msg('b', 'new')] + const out = reuseMessageIdentities(prev, next) + expect(out[0]).toBe(prev[0]) // existing row keeps its reference + expect(out[1]).toBe(next[1]) // appended row is the fresh object + }) + + it('returns the next array untouched when nothing was reusable (no false churn signal)', () => { + const next = [msg('a', 'hi')] + expect(reuseMessageIdentities([], next)).toBe(next) + }) +}) diff --git a/__tests__/unit/hooks/useComposerState.test.ts b/__tests__/unit/hooks/useComposerState.test.ts index 6e750483..c5a4202f 100644 --- a/__tests__/unit/hooks/useComposerState.test.ts +++ b/__tests__/unit/hooks/useComposerState.test.ts @@ -130,3 +130,36 @@ describe('useComposerState', () => { expect(result.current.pendingArgCommand).toBeNull() }) }) + +describe('path escaping for @references', () => { + it('escapes spaces in paths', () => { + const escapePath = (p: string) => p.replace(/ /g, '\\ ') + expect(escapePath('/tmp/My Photo.jpg')).toBe('/tmp/My\\ Photo.jpg') + expect(escapePath('/path/with multiple spaces/file.jpg')).toBe('/path/with\\ multiple\\ spaces/file.jpg') + expect(escapePath('/no-spaces.jpg')).toBe('/no-spaces.jpg') + }) + + it('builds correct payload with multiple attachments', () => { + const escapePath = (p: string) => p.replace(/ /g, '\\ ') + const attachments = [ + { path: '/tmp/My Photo.jpg' }, + { path: '/tmp/Another Image.png' }, + ] + const refs = attachments.map((a) => `@${escapePath(a.path)}`).join(' ') + expect(refs).toBe('@/tmp/My\\ Photo.jpg @/tmp/Another\\ Image.png') + + const text = 'what are these?' + const payload = `${refs} ${text}` + expect(payload).toBe('@/tmp/My\\ Photo.jpg @/tmp/Another\\ Image.png what are these?') + }) + + it('builds attachments-only payload when no text', () => { + const escapePath = (p: string) => p.replace(/ /g, '\\ ') + const attachments = [ + { path: '/tmp/file1.jpg' }, + { path: '/tmp/file2.png' }, + ] + const refs = attachments.map((a) => `@${escapePath(a.path)}`).join(' ') + expect(refs).toBe('@/tmp/file1.jpg @/tmp/file2.png') + }) +}) diff --git a/__tests__/unit/hooks/useConversations.test.tsx b/__tests__/unit/hooks/useConversations.test.tsx index be87e8cf..db065e48 100644 --- a/__tests__/unit/hooks/useConversations.test.tsx +++ b/__tests__/unit/hooks/useConversations.test.tsx @@ -69,7 +69,7 @@ const metaHandlers: Record< jest.mock('@/services/api-client', () => ({ createApiForServer: (serverId: string) => ({ - get: (path: string) => { + get: (path: string, _opts?: { signal?: AbortSignal }) => { const h = handlers[serverId] if (!h) return Promise.reject(new Error(`no handler for ${serverId}`)) return h(path) @@ -475,6 +475,22 @@ describe('useConversations — partial failure (Bug 32)', () => { expect(statuses['srv-B']?.error).toContain('host unreachable') }) + it('preserves an explicit warm-up response instead of recording a fetch error', async () => { + setActiveServers(['srv-A']) + handlers['srv-A'] = () => Promise.reject({ + code: 'SERVER_WARMING_UP', + warmupState: 'startup', + }) + + const { result } = await renderHook(() => useConversations(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(useServerFetchStatusStore.getState().statuses['srv-A']).toMatchObject({ + status: 'warming_up', + warmupState: 'startup', + }) + }) + it('single failing server still surfaces as a query error', async () => { setActiveServers(['srv-A']) handlers['srv-A'] = () => Promise.reject(new Error('down')) @@ -483,6 +499,33 @@ describe('useConversations — partial failure (Bug 32)', () => { await waitFor(() => expect(result.current.isError).toBe(true)) expect(result.current.data).toBeUndefined() }) + + it('keeps healthy results when peers are slow, offline, or malformed', async () => { + setActiveServers(['srv-healthy', 'srv-slow', 'srv-offline', 'srv-malformed']) + + handlers['srv-healthy'] = () => + Promise.resolve([rawSession('healthy-1')]) as Promise + handlers['srv-slow'] = () => + new Promise((resolve) => { + setTimeout(() => resolve([rawSession('slow-1')]), 40) + }) + handlers['srv-offline'] = () => Promise.reject(new Error('ECONNREFUSED')) + handlers['srv-malformed'] = () => Promise.reject(new Error('Unexpected token < in JSON')) + + const { result } = await renderHook(() => useConversations(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.data?.pages.length).toBe(1)) + + const ids = result.current.data!.pages[0].conversations.map((c) => c.id) + expect(ids).toEqual(expect.arrayContaining(['healthy-1', 'slow-1'])) + expect(ids).toHaveLength(2) + expect(result.current.isError).toBe(false) + + const statuses = useServerFetchStatusStore.getState().statuses + expect(statuses['srv-healthy']?.status).toBe('ok') + expect(statuses['srv-slow']?.status).toBe('ok') + expect(statuses['srv-offline']?.status).toBe('error') + expect(statuses['srv-malformed']?.status).toBe('error') + }) }) describe('useConversationSearch — partial failure (Bug 32)', () => { @@ -558,7 +601,7 @@ describe('useEagerConversations — cold-start count (fix: no refresh=1)', () => } }) - it('records indexing (not error) when the count request times out', async () => { + it('records an error when the count request times out', async () => { setActiveServers(['srv-slow']) handlers['srv-slow'] = (path: string) => { @@ -575,7 +618,31 @@ describe('useEagerConversations — cold-start count (fix: no refresh=1)', () => await waitFor(() => expect(result.current.isDone).toBe(true)) const statuses = useServerFetchStatusStore.getState().statuses - expect(statuses['srv-slow']?.status).toBe('indexing') + expect(statuses['srv-slow']?.status).toBe('error') + }) + + it('records warming_up only for the explicit server status', async () => { + setActiveServers(['srv-warm']) + + handlers['srv-warm'] = (path: string) => { + if (path.includes('/api/conversations/count')) { + return Promise.reject({ + code: 'SERVER_WARMING_UP', + warmupState: 'conversation_refresh', + }) + } + return Promise.resolve([]) as Promise + } + + const { result } = await renderHook(() => useEagerConversations(), { + wrapper: createWrapper(), + }) + await waitFor(() => expect(result.current.isDone).toBe(true)) + + expect(useServerFetchStatusStore.getState().statuses['srv-warm']).toMatchObject({ + status: 'warming_up', + warmupState: 'conversation_refresh', + }) }) }) diff --git a/__tests__/unit/hooks/useTBPair.test.ts b/__tests__/unit/hooks/useTBPair.test.ts new file mode 100644 index 00000000..9cbe7a6f --- /dev/null +++ b/__tests__/unit/hooks/useTBPair.test.ts @@ -0,0 +1,173 @@ +import { renderHook, act } from '@testing-library/react-native' +import { useTBPair } from '@/hooks/useTBPair' +import * as pairExchange from '@/services/pair-exchange' + +jest.mock('@/services/pair-exchange', () => { + const actual = jest.requireActual( + '@/services/pair-exchange', + ) + return { + ...actual, + exchangeToken: jest.fn(), + } +}) + +jest.mock('@/services/pair-device-name', () => ({ + defaultPairDeviceName: () => 'Test Phone', +})) + +const exchangeToken = pairExchange.exchangeToken as jest.MockedFunction< + typeof pairExchange.exchangeToken +> + +// React Native declares `__DEV__` as a bare `const`, not a property of +// globalThis, so it is neither assignable nor reachable via `global.__DEV__`. +// This alias is the narrowest way to flip it for the prod-path tests. +const globalWithDev = global as typeof global & { __DEV__: boolean } + +describe('useTBPair (prod path)', () => { + const prevDev = globalWithDev.__DEV__ + + beforeEach(() => { + jest.useFakeTimers() + globalWithDev.__DEV__ = false + exchangeToken.mockReset() + global.fetch = jest.fn() + }) + + afterEach(() => { + jest.useRealTimers() + globalWithDev.__DEV__ = prevDev + }) + + it('exchanges a pt_ pair token then returns the sealed api key', async () => { + exchangeToken.mockResolvedValue({ + url: 'https://example.test', + apiKey: 'tb_sealed_key', + publicUrl: 'https://example.test', + machineName: null, + deviceId: 'dev-1', + deviceToken: 'dt_1', + capabilities: ['history:read', 'session:control'], + }) + + const onSuccess = jest.fn() + const { result } = await renderHook(() => useTBPair()) + + await act(() => { + result.current.pair({ + url: 'https://example.test', + token: 'pt_abcdef', + onSuccess, + }) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(exchangeToken).toHaveBeenCalledWith({ + url: 'https://example.test', + token: 'pt_abcdef', + deviceName: 'Test Phone', + readOnly: false, + }) + expect(global.fetch).not.toHaveBeenCalled() + + await act(() => { + jest.advanceTimersByTime(2400) + }) + + expect(onSuccess).toHaveBeenCalledWith({ + url: 'https://example.test', + apiKey: 'tb_sealed_key', + deviceId: 'dev-1', + deviceToken: 'dt_1', + capabilities: ['history:read', 'session:control'], + }) + expect(result.current.phase).toBe('ok') + }) + + it('parses a threadbase:// URI and exchanges its embedded token', async () => { + exchangeToken.mockResolvedValue({ + url: 'https://from-uri.test', + apiKey: 'tb_from_uri', + publicUrl: 'https://from-uri.test', + machineName: null, + deviceId: null, + deviceToken: null, + capabilities: null, + }) + + const onSuccess = jest.fn() + const { result } = await renderHook(() => useTBPair()) + const uri = + 'threadbase://pair?url=https%3A%2F%2Ffrom-uri.test&token=pt_uri_tok' + + await act(() => { + result.current.pair({ url: '', token: uri, onSuccess }) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(exchangeToken).toHaveBeenCalledWith({ + url: 'https://from-uri.test', + token: 'pt_uri_tok', + deviceName: 'Test Phone', + readOnly: false, + }) + + await act(() => { + jest.advanceTimersByTime(2400) + }) + + expect(onSuccess).toHaveBeenCalledWith({ + url: 'https://from-uri.test', + apiKey: 'tb_from_uri', + }) + }) + + it('Bearer-checks long-lived API keys via /api/profiles', async () => { + ;(global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ([]), + }) + + const onSuccess = jest.fn() + const { result } = await renderHook(() => useTBPair()) + + await act(() => { + result.current.pair({ + url: 'https://example.test', + token: 'tb_df11da2b8b037fd61d82349d182a87b6', + onSuccess, + }) + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(exchangeToken).not.toHaveBeenCalled() + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.test/api/profiles', + expect.objectContaining({ + headers: { + Authorization: 'Bearer tb_df11da2b8b037fd61d82349d182a87b6', + }, + }), + ) + + await act(() => { + jest.advanceTimersByTime(2400) + }) + + expect(onSuccess).toHaveBeenCalledWith({ + url: 'https://example.test', + apiKey: 'tb_df11da2b8b037fd61d82349d182a87b6', + }) + }) +}) diff --git a/__tests__/unit/hooks/useTerminalStream.emptyReplay.test.tsx b/__tests__/unit/hooks/useTerminalStream.emptyReplay.test.tsx new file mode 100644 index 00000000..af9bc352 --- /dev/null +++ b/__tests__/unit/hooks/useTerminalStream.emptyReplay.test.tsx @@ -0,0 +1,113 @@ +import { renderHook, act } from '@testing-library/react-native' +import { useTerminalStream } from '@/hooks/useTerminalStream' +import { createWrapper } from '@/test-utils' + +// ── Controllable wsManager fake ────────────────────────────────────────────── +// Mirrors useTerminalStream.watchdog.test.tsx: a message fires its typed +// handlers, then '*'. Lets us inject a terminal_replay frame at will. +type Handler = (msg: { type: string; sessionId?: string; lines?: string[] }) => void +type StatusListener = (serverId: string, s: string) => void + +jest.mock('@/services/ws-client', () => { + const handlers = new Map>() + const statusListeners = new Set() + const fakeClient = { + send: jest.fn(), + status: () => 'connected', + on: (type: string, h: Handler) => { + if (!handlers.has(type)) handlers.set(type, new Set()) + handlers.get(type)!.add(h) + return () => handlers.get(type)?.delete(h) + }, + } + return { + wsManager: { + getClient: () => fakeClient, + forceReconnect: jest.fn(), + status: () => 'connected', + onAnyStatusChange: (l: StatusListener) => { + statusListeners.add(l) + return () => statusListeners.delete(l) + }, + }, + __wsTest: { + emit: (msg: { type: string; sessionId?: string; lines?: string[] }) => { + handlers.get(msg.type)?.forEach((h) => h(msg)) + handlers.get('*')?.forEach((h) => h(msg)) + }, + reset: () => { + handlers.clear() + statusListeners.clear() + }, + }, + } +}) + +// HTTP fallback — the streamer's /output endpoint. Returns the full transcript +// the WS replay was missing. `mockApiGet` lets each test assert whether the fallback +// was consulted and hand back content. +const mockApiGet = jest.fn() +jest.mock('@/services/api-client', () => ({ + createApiForServer: () => ({ get: (...a: unknown[]) => mockApiGet(...a) }), + NotFoundError: class NotFoundError extends Error {}, +})) + +const { __wsTest } = jest.requireMock('@/services/ws-client') as { + __wsTest: { + emit: (msg: { type: string; sessionId?: string; lines?: string[] }) => void + reset: () => void + } +} + +const TERMINAL_REPLAY_TIMEOUT_MS = 2000 + +async function renderStream() { + return await renderHook(() => useTerminalStream('srv-1', 'sess-1'), { wrapper: createWrapper() }) +} + +beforeEach(() => { + jest.useFakeTimers() + __wsTest.reset() + mockApiGet.mockReset() + mockApiGet.mockResolvedValue({ output: 'FULL TRANSCRIPT FROM HTTP\n' }) +}) + +afterEach(() => { + jest.useRealTimers() +}) + +describe('useTerminalStream – empty terminal_replay fallback', () => { + it('falls back to HTTP /output when the WS replay carries no renderable lines', async () => { + await renderStream() + + // Server unicasts a terminal_replay with only blank lines — the case a + // card-parked session produces. This must NOT count as a successful load. + await act(() => __wsTest.emit({ type: 'terminal_replay', sessionId: 'sess-1', lines: ['', ' ', ''] })) + + // The 2s fallback timer must still be armed → HTTP /output gets fetched. + await act(async () => { + jest.advanceTimersByTime(TERMINAL_REPLAY_TIMEOUT_MS) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(mockApiGet).toHaveBeenCalledWith('/api/sessions/sess-1/output') + }) + + it('does NOT fall back when the WS replay carries real content', async () => { + await renderStream() + + await act(() => __wsTest.emit({ type: 'terminal_replay', sessionId: 'sess-1', lines: ['hello world'] })) + + await act(async () => { + jest.advanceTimersByTime(TERMINAL_REPLAY_TIMEOUT_MS) + }) + await act(async () => { + await Promise.resolve() + }) + + // A non-empty replay is authoritative — the HTTP fallback stays disabled. + expect(mockApiGet).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/lib/eagerCacheSync.test.ts b/__tests__/unit/lib/eagerCacheSync.test.ts new file mode 100644 index 00000000..895ca0e5 --- /dev/null +++ b/__tests__/unit/lib/eagerCacheSync.test.ts @@ -0,0 +1,78 @@ +import { QueryClient } from '@tanstack/react-query' +import { applySessionUpdateToEagerCache, refreshEagerConversations } from '@/lib/eagerCacheSync' +import type { MultiSession, Session } from '@/types/api' + +function makeSession(overrides: Partial = {}): Session { + return { + id: 'sess-1', + status: 'idle', + ptyAttached: false, + projectPath: '/tmp/p', + projectName: 'p', + lastOutput: '', + elapsedMs: 0, + promptCount: 0, + startedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +const EAGER_KEY = ['sessions-eager', undefined, 'startedAt', 'all', 'srv1'] as const + +describe('applySessionUpdateToEagerCache', () => { + it('patches an existing row in place without invalidating', () => { + const qc = new QueryClient() + const existing: MultiSession[] = [ + { ...makeSession({ id: 'sess-1', status: 'running' }), serverId: 'srv1' }, + ] + qc.setQueryData(EAGER_KEY, existing) + const invalidateSpy = jest.spyOn(qc, 'invalidateQueries') + + applySessionUpdateToEagerCache(qc, 'srv1', makeSession({ id: 'sess-1', status: 'waiting_input' })) + + const next = qc.getQueryData(EAGER_KEY) + expect(next?.[0].status).toBe('waiting_input') + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('invalidates when the session is not in any eager list yet', () => { + const qc = new QueryClient() + qc.setQueryData(EAGER_KEY, [ + { ...makeSession({ id: 'other' }), serverId: 'srv1' }, + ] as MultiSession[]) + const invalidateSpy = jest.spyOn(qc, 'invalidateQueries') + + applySessionUpdateToEagerCache( + qc, + 'srv1', + makeSession({ id: 'new-external', ownership: 'external', processLiveness: 'alive' }), + ) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sessions-eager'] }) + // The non-matching list is left untouched (no blind insert). + expect(qc.getQueryData(EAGER_KEY)).toHaveLength(1) + }) + + it('does not match a same-id row on a different server', () => { + const qc = new QueryClient() + qc.setQueryData(EAGER_KEY, [ + { ...makeSession({ id: 'sess-1' }), serverId: 'srv1' }, + ] as MultiSession[]) + const invalidateSpy = jest.spyOn(qc, 'invalidateQueries') + + applySessionUpdateToEagerCache(qc, 'srv2', makeSession({ id: 'sess-1' })) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sessions-eager'] }) + }) +}) + +describe('refreshEagerConversations', () => { + it('invalidates the eager conversations list', () => { + const qc = new QueryClient() + const invalidateSpy = jest.spyOn(qc, 'invalidateQueries') + + refreshEagerConversations(qc) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['conversations-eager'] }) + }) +}) diff --git a/__tests__/unit/lib/externalSession.test.ts b/__tests__/unit/lib/externalSession.test.ts new file mode 100644 index 00000000..6033fe43 --- /dev/null +++ b/__tests__/unit/lib/externalSession.test.ts @@ -0,0 +1,52 @@ +import { isExternalSession, isExternalAlive } from '@/lib/externalSession' +import type { Session } from '@/types/api' + +describe('isExternalSession', () => { + it('is true only for ownership "external"', () => { + expect(isExternalSession({ ownership: 'external' })).toBe(true) + expect(isExternalSession({ ownership: 'managed' })).toBe(false) + expect(isExternalSession({ ownership: 'historical' })).toBe(false) + }) + + it('is false when ownership is omitted (older server)', () => { + expect(isExternalSession({ ownership: undefined })).toBe(false) + }) +}) + +describe('isExternalAlive', () => { + it('trusts processLiveness when present', () => { + expect(isExternalAlive({ processLiveness: 'alive' })).toBe(true) + expect(isExternalAlive({ processLiveness: 'gone' })).toBe(false) + }) + + it('treats an active_writing JSONL as alive', () => { + expect( + isExternalAlive({ activity: { state: 'active_writing', lastEventAt: 'x', source: 'jsonl' } }), + ).toBe(true) + }) + + it('does not treat a quiet JSONL as alive', () => { + expect( + isExternalAlive({ activity: { state: 'quiet', lastEventAt: 'x', source: 'jsonl' } }), + ).toBe(false) + }) + + it('gone wins over a present pid', () => { + expect(isExternalAlive({ processLiveness: 'gone', pid: 4242 })).toBe(false) + }) + + it('falls back to a bare pid for older servers with no liveness fields', () => { + expect(isExternalAlive({ pid: 4242 })).toBe(true) + }) + + it('does not apply the pid fallback once a new field is present', () => { + // processLiveness 'unknown' is a new-server signal — the pid fallback must + // not override it into "alive". + expect(isExternalAlive({ processLiveness: 'unknown', pid: 4242 })).toBe(false) + }) + + it('is false for a managed session (no pid, no new fields)', () => { + const managed: Pick = {} + expect(isExternalAlive(managed)).toBe(false) + }) +}) diff --git a/__tests__/unit/lib/projectDisambiguation.test.ts b/__tests__/unit/lib/projectDisambiguation.test.ts new file mode 100644 index 00000000..cddb8430 --- /dev/null +++ b/__tests__/unit/lib/projectDisambiguation.test.ts @@ -0,0 +1,34 @@ +import { + collidingProjectPaths, + shouldForceServerChip, +} from '@/lib/projectDisambiguation' + +describe('projectDisambiguation', () => { + it('flags paths that appear on more than one server', () => { + const colliding = collidingProjectPaths([ + { projectPath: '/home/a/proj', serverId: 's1' }, + { projectPath: '/home/a/proj', serverId: 's2' }, + { projectPath: '/home/a/other', serverId: 's1' }, + { projectPath: ' /home/a/proj ', serverId: 's1' }, + ]) + expect([...colliding]).toEqual(['/home/a/proj']) + }) + + it('ignores blank paths and single-server duplicates', () => { + expect( + collidingProjectPaths([ + { projectPath: '', serverId: 's1' }, + { projectPath: null, serverId: 's2' }, + { projectPath: '/solo', serverId: 's1' }, + { projectPath: '/solo', serverId: 's1' }, + ]).size, + ).toBe(0) + }) + + it('shouldForceServerChip only for colliding paths', () => { + const colliding = new Set(['/shared']) + expect(shouldForceServerChip('/shared', colliding)).toBe(true) + expect(shouldForceServerChip('/other', colliding)).toBe(false) + expect(shouldForceServerChip(undefined, colliding)).toBe(false) + }) +}) diff --git a/__tests__/unit/lib/renderConfidence.test.ts b/__tests__/unit/lib/renderConfidence.test.ts new file mode 100644 index 00000000..25deec42 --- /dev/null +++ b/__tests__/unit/lib/renderConfidence.test.ts @@ -0,0 +1,80 @@ +import { parseConfidenceFromCounters, preferRawTerminal } from '@/lib/renderConfidence' + +describe('preferRawTerminal', () => { + it('forces terminal without a conversation id', () => { + const result = preferRawTerminal({ + sessionView: 'chat', + hasConversationId: false, + conversationMessageCount: 0, + ptyVisibleLineCount: 0, + parseConfidence: 'high', + }) + expect(result).toEqual({ + mode: 'terminal', + reason: 'no_conversation', + chatAuthoritative: false, + }) + }) + + it('forces terminal on low parse confidence', () => { + const result = preferRawTerminal({ + sessionView: 'chat', + hasConversationId: true, + conversationMessageCount: 3, + ptyVisibleLineCount: 10, + parseConfidence: 'low', + }) + expect(result.mode).toBe('terminal') + expect(result.chatAuthoritative).toBe(false) + expect(result.reason).toBe('low_parse_confidence') + }) + + it('forces terminal when chat is empty but PTY is active', () => { + const result = preferRawTerminal({ + sessionView: 'chat', + hasConversationId: true, + conversationMessageCount: 0, + ptyVisibleLineCount: 40, + parseConfidence: 'high', + }) + expect(result.reason).toBe('chat_empty_pty_active') + expect(result.chatAuthoritative).toBe(false) + }) + + it('keeps chat when normalization looks healthy', () => { + const result = preferRawTerminal({ + sessionView: 'chat', + hasConversationId: true, + conversationMessageCount: 2, + ptyVisibleLineCount: 40, + parseConfidence: 'high', + }) + expect(result).toEqual({ + mode: 'chat', + reason: 'user_preference', + chatAuthoritative: true, + }) + }) +}) + +describe('parseConfidenceFromCounters', () => { + it('stays high for clean streams', () => { + expect( + parseConfidenceFromCounters({ + unsupportedSequenceCount: 0, + truncatedEscapeCount: 0, + bytesFed: 10_000, + }), + ).toBe('high') + }) + + it('drops to low when unsupported sequences pile up', () => { + expect( + parseConfidenceFromCounters({ + unsupportedSequenceCount: 20, + truncatedEscapeCount: 0, + bytesFed: 2_000, + }), + ).toBe('low') + }) +}) diff --git a/__tests__/unit/lib/reviewFromConversation.test.ts b/__tests__/unit/lib/reviewFromConversation.test.ts new file mode 100644 index 00000000..16e6884c --- /dev/null +++ b/__tests__/unit/lib/reviewFromConversation.test.ts @@ -0,0 +1,95 @@ +import { + buildReviewFromMessages, + formatReviewNote, + hunkFromEdit, + hunkFromWrite, +} from '@/lib/reviewFromConversation' +import type { Message } from '@/types/api' + +function msg(content: Message['content']): Message { + return { + id: '1', + uuid: 'u1', + role: 'assistant', + content, + timestamp: '', + is_sidechain: false, + parent_uuid: null, + } +} + +describe('reviewFromConversation', () => { + it('builds hunks from Edit tool input', () => { + const summary = buildReviewFromMessages([ + msg([ + { + type: 'tool_use', + name: 'Edit', + input: { + file_path: 'src/a.ts', + old_string: 'const x = 1', + new_string: 'const x = 2', + }, + }, + ]), + ]) + expect(summary.files).toHaveLength(1) + expect(summary.files[0].path).toBe('src/a.ts') + expect(summary.files[0].kind).toBe('edited') + expect(summary.files[0].added).toBe(1) + expect(summary.files[0].removed).toBe(1) + expect(summary.incomplete).toBe(false) + }) + + it('treats Write as all additions', () => { + const summary = buildReviewFromMessages([ + msg([ + { + type: 'tool_use', + name: 'Write', + input: { file_path: 'src/b.ts', content: 'one\ntwo\nthree' }, + }, + ]), + ]) + expect(summary.files[0].kind).toBe('written') + expect(summary.files[0].added).toBe(3) + expect(summary.files[0].removed).toBe(0) + }) + + it('marks incomplete when edit payload is missing', () => { + const summary = buildReviewFromMessages([ + msg([{ type: 'tool_use', name: 'Edit', input: { file_path: 'x.ts' } }]), + ]) + expect(summary.incomplete).toBe(true) + expect(summary.files[0].incompleteReasons).toContain('missing_edit_payload') + }) + + it('includes structured diff blocks', () => { + const summary = buildReviewFromMessages([ + msg([ + { + type: 'diff', + filename: 'readme.md', + hunks: [hunkFromEdit('a', 'b')], + }, + ]), + ]) + expect(summary.files[0].kind).toBe('diff') + expect(summary.totalAdded).toBe(1) + }) + + it('formats a compact review note', () => { + const summary = buildReviewFromMessages([ + msg([ + { + type: 'tool_use', + name: 'Write', + input: { file_path: 'a.ts', content: hunkFromWrite('x').lines.map((l) => l.content).join('\n') }, + }, + ]), + ]) + const note = formatReviewNote(summary, 'a.ts') + expect(note).toContain('Focus: a.ts') + expect(note).toContain('a.ts') + }) +}) diff --git a/__tests__/unit/lib/serverUrl.test.ts b/__tests__/unit/lib/serverUrl.test.ts new file mode 100644 index 00000000..f60ccaba --- /dev/null +++ b/__tests__/unit/lib/serverUrl.test.ts @@ -0,0 +1,16 @@ +import { isValidHttpServerUrl } from '@/lib/serverUrl' + +describe('isValidHttpServerUrl', () => { + it('accepts http and https hosts with optional ports', () => { + expect(isValidHttpServerUrl('http://192.168.1.10:8766')).toBe(true) + expect(isValidHttpServerUrl('https://streamer.example.com')).toBe(true) + }) + + it('rejects empty, schemeless, and non-http schemes', () => { + expect(isValidHttpServerUrl('')).toBe(false) + expect(isValidHttpServerUrl(' ')).toBe(false) + expect(isValidHttpServerUrl('192.168.1.10:8766')).toBe(false) + expect(isValidHttpServerUrl('ftp://example.com')).toBe(false) + expect(isValidHttpServerUrl('not a url')).toBe(false) + }) +}) diff --git a/__tests__/unit/lib/sessionLifecycle.test.ts b/__tests__/unit/lib/sessionLifecycle.test.ts new file mode 100644 index 00000000..b2e5f05e --- /dev/null +++ b/__tests__/unit/lib/sessionLifecycle.test.ts @@ -0,0 +1,95 @@ +import { QueryClient } from '@tanstack/react-query' +import { + evictStaleConversationFavorite, + evictStaleSessionFavorite, + rehydrateSessionAfterReconnect, + removeSessionFromEagerCache, +} from '@/lib/sessionLifecycle' +import { useQuickAccessStore, buildFavoriteId } from '@/stores/quickAccess' +import type { MultiSession } from '@/types/api' + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), +})) + +describe('sessionLifecycle', () => { + beforeEach(() => { + useQuickAccessStore.setState({ favorites: [] }) + }) + + it('removes a vanished session from every eager hub list and detail cache', () => { + const qc = new QueryClient() + const row = { serverId: 's1', id: 'sess-1' } as MultiSession + const other = { serverId: 's1', id: 'sess-2' } as MultiSession + qc.setQueryData(['sessions-eager', 's1'], [row, other]) + qc.setQueryData(['session', 's1', 'sess-1'], row) + + removeSessionFromEagerCache(qc, 's1', 'sess-1') + + expect(qc.getQueryData(['sessions-eager', 's1'])).toEqual([other]) + expect(qc.getQueryData(['session', 's1', 'sess-1'])).toBeUndefined() + }) + + it('evicts canonical and legacy session favorites for a vanished id', () => { + const canonical = buildFavoriteId('s1', 'session', 'gone') + useQuickAccessStore.setState({ + favorites: [ + { type: 'session', id: canonical, label: 'A', serverId: 's1', sessionId: 'gone' }, + { type: 'session', id: 'legacy::gone', label: 'B', serverId: 's1' }, + { type: 'session', id: buildFavoriteId('s1', 'session', 'keep'), label: 'C', serverId: 's1', sessionId: 'keep' }, + { type: 'dir', id: '/tmp', label: 'dir' }, + ], + }) + + evictStaleSessionFavorite('s1', 'gone') + + const left = useQuickAccessStore.getState().favorites + expect(left.map((f) => f.id)).toEqual([ + buildFavoriteId('s1', 'session', 'keep'), + '/tmp', + ]) + }) + + it('evicts conversation favorites for a vanished conversation id', () => { + const canonical = buildFavoriteId('s1', 'conversation', 'c-gone') + useQuickAccessStore.setState({ + favorites: [ + { type: 'conversation', id: canonical, label: 'A', serverId: 's1', conversationId: 'c-gone' }, + { + type: 'conversation', + id: buildFavoriteId('s1', 'conversation', 'c-keep'), + label: 'B', + serverId: 's1', + conversationId: 'c-keep', + }, + ], + }) + + evictStaleConversationFavorite('s1', 'c-gone') + + expect(useQuickAccessStore.getState().favorites.map((f) => f.id)).toEqual([ + buildFavoriteId('s1', 'conversation', 'c-keep'), + ]) + }) + + it('rehydrates session and bound conversation queries after reconnect', () => { + const qc = new QueryClient() + const invalidate = jest.spyOn(qc, 'invalidateQueries') + + rehydrateSessionAfterReconnect(qc, 's1', 'sess-1', 'conv-9') + + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['session', 's1', 'sess-1'] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['conversation', 's1', 'conv-9'] }) + }) + + it('skips conversation invalidation when no conversation id is known', () => { + const qc = new QueryClient() + const invalidate = jest.spyOn(qc, 'invalidateQueries') + + rehydrateSessionAfterReconnect(qc, 's1', 'sess-1', null) + + expect(invalidate).toHaveBeenCalledTimes(1) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['session', 's1', 'sess-1'] }) + }) +}) diff --git a/__tests__/unit/lib/sessionPresentation.test.ts b/__tests__/unit/lib/sessionPresentation.test.ts new file mode 100644 index 00000000..bb9216f9 --- /dev/null +++ b/__tests__/unit/lib/sessionPresentation.test.ts @@ -0,0 +1,110 @@ +import { + deriveConversationPresentation, + deriveSessionPresentation, +} from '@/lib/sessionPresentation' + +function base( + overrides: Partial[0]> & { status: string }, +) { + return { + ptyAttached: true, + ownership: 'managed' as const, + ...overrides, + } +} + +describe('deriveSessionPresentation', () => { + it('marks managed running / waiting as live with control', () => { + expect(deriveSessionPresentation(base({ status: 'running' }))).toMatchObject({ + kind: 'managed_live', + live: true, + capabilities: { canSendInput: true, canCancel: true, isObserveOnly: false }, + }) + expect(deriveSessionPresentation(base({ status: 'waiting_input' })).labelKey).toBe( + 'status.waiting', + ) + }) + + it('marks resumed live sessions distinctly', () => { + expect( + deriveSessionPresentation( + base({ status: 'running', resumedFromConversationId: 'c1' }), + ), + ).toMatchObject({ + kind: 'resumed', + live: true, + capabilities: { canSendInput: true }, + }) + }) + + it('marks external alive as observe-only live', () => { + expect( + deriveSessionPresentation( + base({ + status: 'idle', + ownership: 'external', + processLiveness: 'alive', + ptyAttached: false, + }), + ), + ).toMatchObject({ + kind: 'external_live', + live: true, + externalLive: true, + capabilities: { isObserveOnly: true, canCancel: false }, + }) + }) + + it('marks on_hold and completed/failed from runtime status strings', () => { + expect(deriveSessionPresentation(base({ status: 'on_hold' })).kind).toBe('on_hold') + expect(deriveSessionPresentation(base({ status: 'completed' })).kind).toBe('completed') + expect(deriveSessionPresentation(base({ status: 'failed' })).labelKey).toBe('status.failed') + }) + + it('marks gone external processes as stale', () => { + expect( + deriveSessionPresentation( + base({ + status: 'idle', + ownership: 'external', + processLiveness: 'gone', + ptyAttached: false, + }), + ).kind, + ).toBe('stale') + }) + + it('marks historical ownership as resumable history', () => { + expect( + deriveSessionPresentation( + base({ + status: 'idle', + ownership: 'historical', + ptyAttached: false, + }), + ), + ).toMatchObject({ + kind: 'historical', + capabilities: { canResume: true, isObserveOnly: true }, + }) + }) +}) + +describe('deriveConversationPresentation', () => { + it('returns unavailable when resume is blocked', () => { + expect( + deriveConversationPresentation({ + resumable: false, + unavailableReason: 'path_missing', + }), + ).toMatchObject({ + kind: 'unavailable', + labelKey: 'status.unavailablePath', + capabilities: { canResume: false }, + }) + }) + + it('returns null when conversation is resumable', () => { + expect(deriveConversationPresentation({ resumable: true })).toBeNull() + }) +}) diff --git a/__tests__/unit/lib/terminalChrome.test.ts b/__tests__/unit/lib/terminalChrome.test.ts new file mode 100644 index 00000000..ef7e7c90 --- /dev/null +++ b/__tests__/unit/lib/terminalChrome.test.ts @@ -0,0 +1,29 @@ +import { + getTerminalChromeFilter, + isClaudeTerminalChrome, + isCodexTerminalChrome, + keepTranscriptLine, +} from '@/lib/terminalChrome' + +describe('terminalChrome adapters', () => { + it('filters Claude Code spinner and banner chrome', () => { + expect(isClaudeTerminalChrome('Welcome to Claude Code')).toBe(true) + expect(isClaudeTerminalChrome('· Thinking…')).toBe(true) + expect(isClaudeTerminalChrome('❯ ship the fix')).toBe(false) + }) + + it('keeps Codex non-chrome lines and drops empty separators', () => { + expect(isCodexTerminalChrome('────')).toBe(true) + expect(isCodexTerminalChrome('editing main.ts')).toBe(false) + }) + + it('selects passthrough for unknown providers', () => { + const filter = getTerminalChromeFilter('mystery-agent') + expect(keepTranscriptLine('any line', filter)).toBe(true) + }) + + it('raw mode keeps non-empty lines', () => { + const filter = getTerminalChromeFilter('claude-code', { raw: true }) + expect(keepTranscriptLine('Welcome to Claude Code', filter)).toBe(true) + }) +}) diff --git a/__tests__/unit/services/api-client.test.ts b/__tests__/unit/services/api-client.test.ts index d9eef002..9c40fa05 100644 --- a/__tests__/unit/services/api-client.test.ts +++ b/__tests__/unit/services/api-client.test.ts @@ -1,4 +1,5 @@ import { createApiForServer, NetworkError, AuthError, NotFoundError } from '@/services/api-client' +import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' jest.mock('@/stores/servers', () => ({ useServersStore: { @@ -39,6 +40,7 @@ function mockErrorResponse(status: number) { beforeEach(() => { mockFetch.mockReset() + useServerFetchStatusStore.getState().reset() }) describe('Error classes', () => { @@ -96,6 +98,51 @@ describe('api.get', () => { await expect(api.get('/api/broken')).rejects.toThrow(NetworkError) }) + it('records the explicit server warm-up response for fetch endpoints', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + json: jest.fn().mockResolvedValue({ + error: 'Server is warming up', + code: 'SERVER_WARMING_UP', + warmupState: 'cache_reset', + }), + }) + + await expect(api.get('/api/conversations/count')).rejects.toMatchObject({ + code: 'SERVER_WARMING_UP', + warmupState: 'cache_reset', + }) + expect(useServerFetchStatusStore.getState().statuses.srv_test).toMatchObject({ + status: 'warming_up', + warmupState: 'cache_reset', + }) + }) + + it('does not classify a generic 503 as warm-up', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + json: jest.fn().mockResolvedValue({ error: 'Temporarily unavailable' }), + }) + + await expect(api.get('/api/conversations/count')).rejects.toMatchObject({ + code: undefined, + }) + expect(useServerFetchStatusStore.getState().statuses.srv_test).toBeUndefined() + }) + + it('clears warm-up only after a successful fetch endpoint response', async () => { + useServerFetchStatusStore.getState().recordWarmingUp('srv_test', 'startup') + useServerFetchStatusStore.getState().recordSuccess('srv_test') + expect(useServerFetchStatusStore.getState().statuses.srv_test.status).toBe('warming_up') + + mockFetch.mockResolvedValueOnce(mockOkResponse({ total: 0 })) + await api.get('/api/conversations/count') + + expect(useServerFetchStatusStore.getState().statuses.srv_test.status).toBe('ok') + }) + it('retries once on network failure then throws NetworkError', async () => { mockFetch .mockRejectedValueOnce(new TypeError('Failed to fetch')) diff --git a/__tests__/unit/services/claudeFlagsApi.test.ts b/__tests__/unit/services/claudeFlagsApi.test.ts new file mode 100644 index 00000000..6c4d21ec --- /dev/null +++ b/__tests__/unit/services/claudeFlagsApi.test.ts @@ -0,0 +1,85 @@ +/** + * getClaudeFlags / updateClaudeFlags — the backward-compatibility contract. + * + * A streamer that predates this feature has no /api/config/claude-flags and + * answers 404. That must surface as `null` ("feature absent", hide the UI), not + * as an error, or every user on an older server sees a broken settings screen. + * Driven through a mocked fetch so the real request() path runs. + */ +import { getClaudeFlags, updateClaudeFlags } from '@/services/api-client' +import { useServersStore } from '@/stores/servers' + +const SERVER = { + id: 'srv1', + url: 'http://localhost:8766', + apiKey: 'tb_test', + isConnected: true, + serverInfo: null, + connectionError: null, +} + +function respond(status: number, body: unknown) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + headers: { get: () => 'application/json' }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response) +} + +beforeEach(() => { + useServersStore.setState({ + servers: { srv1: SERVER as never }, + activeServerIds: ['srv1'], + displayedServerIds: ['srv1'], + isLoading: false, + }) + global.fetch = jest.fn() +}) + +describe('getClaudeFlags', () => { + it('returns the config on 200', async () => { + const config = { registry: [], values: {}, extraArgs: null, persisted: true } + ;(global.fetch as jest.Mock).mockReturnValue(respond(200, config)) + + await expect(getClaudeFlags('srv1')).resolves.toEqual(config) + }) + + it('maps a 404 to null so older servers degrade quietly', async () => { + ;(global.fetch as jest.Mock).mockReturnValue(respond(404, { error: 'not found' })) + + await expect(getClaudeFlags('srv1')).resolves.toBeNull() + }) + + // A 403 is the localNoAuth guard — a real, actionable error, not "absent". + it('does not swallow a 403', async () => { + ;(global.fetch as jest.Mock).mockReturnValue(respond(403, { error: 'disabled' })) + + await expect(getClaudeFlags('srv1')).rejects.toBeDefined() + }) +}) + +describe('updateClaudeFlags', () => { + it('PUTs values and omits extraArgs when absent', async () => { + const config = { registry: [], values: {}, extraArgs: null, persisted: true } + ;(global.fetch as jest.Mock).mockReturnValue(respond(200, config)) + + await updateClaudeFlags('srv1', { maxBudgetUsd: '5' }) + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0] + expect(url).toContain('/api/config/claude-flags') + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body)).toEqual({ values: { maxBudgetUsd: '5' } }) + }) + + it('includes extraArgs when provided', async () => { + const config = { registry: [], values: {}, extraArgs: '--bare', persisted: true } + ;(global.fetch as jest.Mock).mockReturnValue(respond(200, config)) + + await updateClaudeFlags('srv1', {}, '--bare') + + const [, init] = (global.fetch as jest.Mock).mock.calls[0] + expect(JSON.parse(init.body)).toEqual({ values: {}, extraArgs: '--bare' }) + }) +}) diff --git a/__tests__/unit/services/pair-exchange.test.ts b/__tests__/unit/services/pair-exchange.test.ts index 929f4167..dad8ea0d 100644 --- a/__tests__/unit/services/pair-exchange.test.ts +++ b/__tests__/unit/services/pair-exchange.test.ts @@ -1,12 +1,35 @@ import nacl from 'tweetnacl' import naclUtil from 'tweetnacl-util' import { + classifyPairCredential, exchangeToken, parsePairUri, PairExchangeError, PairUriError, } from '@/services/pair-exchange' +describe('classifyPairCredential', () => { + it('detects threadbase:// pair URIs', () => { + expect( + classifyPairCredential( + 'threadbase://pair?url=https%3A%2F%2Fa.test&token=pt_abc', + ), + ).toBe('pair-uri') + }) + + it('detects short-lived pt_ pair tokens', () => { + expect(classifyPairCredential('pt_abcdef0123456789')).toBe('pair-token') + expect(classifyPairCredential(' pt_x ')).toBe('pair-token') + }) + + it('treats long-lived API keys as api-key', () => { + expect(classifyPairCredential('tb_df11da2b8b037fd61d82349d182a87b6')).toBe( + 'api-key', + ) + expect(classifyPairCredential('some-other-secret')).toBe('api-key') + }) +}) + describe('parsePairUri', () => { it('parses a well-formed pair URI', () => { const futureExp = Math.floor(Date.now() / 1000) + 180 @@ -98,6 +121,55 @@ describe('exchangeToken', () => { expect(result.publicUrl).toBe('https://example.test') expect(result.machineName).toBe('ronen-mac.local') expect(result.url).toBe('https://example.test') + expect(result.deviceId).toBeNull() + expect(result.deviceToken).toBeNull() + expect(result.capabilities).toBeNull() + }) + + it('forwards deviceName/readOnly and returns device fields', async () => { + const apiKey = 'tb_device_fields' + + global.fetch = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { + token: string + clientPublicKey: string + deviceName?: string + readOnly?: boolean + } + expect(body.deviceName).toBe('Pixel 8') + expect(body.readOnly).toBe(true) + const recipientPk = naclUtil.decodeBase64(body.clientPublicKey) + const ephemeral = nacl.box.keyPair() + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const cipher = nacl.box( + naclUtil.decodeUTF8(apiKey), + nonce, + recipientPk, + ephemeral.secretKey, + ) + return new Response( + JSON.stringify({ + ciphertext: naclUtil.encodeBase64(cipher), + nonce: naclUtil.encodeBase64(nonce), + ephemeralPublicKey: naclUtil.encodeBase64(ephemeral.publicKey), + deviceId: 'uuid-1', + deviceToken: 'dt_secret', + capabilities: ['history:read'], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }) as typeof fetch + + const result = await exchangeToken({ + url: 'https://example.test', + token: 'pt_abc', + deviceName: 'Pixel 8', + readOnly: true, + }) + expect(result.apiKey).toBe(apiKey) + expect(result.deviceId).toBe('uuid-1') + expect(result.deviceToken).toBe('dt_secret') + expect(result.capabilities).toEqual(['history:read']) }) it('surfaces a 401 as a token error', async () => { diff --git a/__tests__/unit/services/push-health.test.ts b/__tests__/unit/services/push-health.test.ts new file mode 100644 index 00000000..b52f7fef --- /dev/null +++ b/__tests__/unit/services/push-health.test.ts @@ -0,0 +1,36 @@ +import { parsePushHealthResponse } from '@/types/push-health' +import { formatEpoch } from '@/services/push-health' + +describe('parsePushHealthResponse', () => { + it('parses available health with tokens', () => { + const parsed = parsePushHealthResponse({ + available: true, + tokens: [ + { + platform: 'ios', + deviceId: 'dev-1', + registeredAt: 1_700_000_000_000, + lastSuccessAt: null, + lastFailureAt: null, + lastFailureCode: null, + failureStreak: 0, + revokedAt: null, + state: 'never-delivered', + }, + ], + }) + expect(parsed?.available).toBe(true) + expect(parsed?.tokens[0].state).toBe('never-delivered') + }) + + it('rejects missing available flag', () => { + expect(parsePushHealthResponse({ tokens: [] })).toBeNull() + }) +}) + +describe('formatEpoch', () => { + it('formats numbers and null', () => { + expect(formatEpoch(null)).toBe('—') + expect(formatEpoch(1_700_000_000_000)).toContain('2023') + }) +}) diff --git a/__tests__/unit/services/push-register-result.test.ts b/__tests__/unit/services/push-register-result.test.ts new file mode 100644 index 00000000..d8cde9a9 --- /dev/null +++ b/__tests__/unit/services/push-register-result.test.ts @@ -0,0 +1,23 @@ +import { registerPushToken } from '@/services/push' + +jest.mock('expo-notifications', () => ({ + setNotificationHandler: jest.fn(), + getPermissionsAsync: jest.fn(async () => ({ granted: false })), + requestPermissionsAsync: jest.fn(), + getExpoPushTokenAsync: jest.fn(), +})) + +jest.mock('@/services/api-client', () => ({ + createApiForServer: () => ({ post: jest.fn() }), +})) + +jest.mock('@/services/device-id', () => ({ + getDeviceClientId: jest.fn(async () => 'device-1'), +})) + +describe('registerPushToken result', () => { + it('reports permission_denied when OS permission is off', async () => { + const result = await registerPushToken('srv1') + expect(result).toEqual({ ok: false, reason: 'permission_denied' }) + }) +}) diff --git a/__tests__/unit/services/server-diagnostics.test.ts b/__tests__/unit/services/server-diagnostics.test.ts new file mode 100644 index 00000000..6266eec9 --- /dev/null +++ b/__tests__/unit/services/server-diagnostics.test.ts @@ -0,0 +1,87 @@ +import { + parseServerDiagnosticsReport, + SERVER_DIAGNOSTICS_CONTRACT_VERSION, +} from '@/types/server-diagnostics' +import { + isSupportedDiagnosticsContract, + needsRemediation, + serverDiagnosticsToText, +} from '@/services/server-diagnostics' + +describe('parseServerDiagnosticsReport', () => { + const valid = { + contractVersion: 1, + generatedAt: '2026-07-24T12:00:00.000Z', + overall: 'degraded', + checks: [ + { + id: 'streamer', + status: 'ok', + summary: 'Streamer is running.', + remediation: 'NONE', + detail: { version: '1.0.0', uptimeSeconds: 12 }, + }, + { + id: 'cache', + status: 'degraded', + summary: 'Conversation cache reported an integrity alert.', + remediation: 'CACHE_DEGRADED', + }, + ], + } + + it('parses a contract v1 payload', () => { + const report = parseServerDiagnosticsReport(valid) + expect(report).not.toBeNull() + expect(report!.contractVersion).toBe(SERVER_DIAGNOSTICS_CONTRACT_VERSION) + expect(report!.overall).toBe('degraded') + expect(report!.checks).toHaveLength(2) + expect(report!.checks[1].remediation).toBe('CACHE_DEGRADED') + }) + + it('rejects missing checks', () => { + expect(parseServerDiagnosticsReport({ contractVersion: 1, generatedAt: 'x', overall: 'ok' })).toBeNull() + }) + + it('skips malformed check entries', () => { + const report = parseServerDiagnosticsReport({ + ...valid, + checks: [valid.checks[0], { id: 'bad' }, valid.checks[1]], + }) + expect(report!.checks.map((c) => c.id)).toEqual(['streamer', 'cache']) + }) +}) + +describe('serverDiagnostics helpers', () => { + const report = parseServerDiagnosticsReport({ + contractVersion: 1, + generatedAt: '2026-07-24T12:00:00.000Z', + overall: 'failed', + checks: [ + { + id: 'pty', + status: 'failed', + summary: 'node-pty failed to load', + remediation: 'PTY_UNAVAILABLE', + }, + ], + })! + + it('flags unsupported contract versions', () => { + expect(isSupportedDiagnosticsContract(report)).toBe(true) + expect(isSupportedDiagnosticsContract({ ...report, contractVersion: 2 })).toBe(false) + }) + + it('needsRemediation ignores NONE', () => { + expect(needsRemediation('NONE')).toBe(false) + expect(needsRemediation('PTY_UNAVAILABLE')).toBe(true) + }) + + it('formats a sanitized plain-text report', () => { + const text = serverDiagnosticsToText('Studio Mac', report) + expect(text).toContain('Studio Mac') + expect(text).toContain('PTY_UNAVAILABLE') + expect(text).toContain('[failed] pty') + expect(text).not.toMatch(/api[_-]?key/i) + }) +}) diff --git a/__tests__/unit/services/virtual-terminal.longOutput.test.ts b/__tests__/unit/services/virtual-terminal.longOutput.test.ts new file mode 100644 index 00000000..92f36266 --- /dev/null +++ b/__tests__/unit/services/virtual-terminal.longOutput.test.ts @@ -0,0 +1,52 @@ +import { VirtualTerminal } from '@/services/virtual-terminal' + +describe('VirtualTerminal long-output resilience', () => { + it('caps scrollback and stays bounded under a long append-only feed', () => { + const vt = new VirtualTerminal() + const started = Date.now() + let payload = '' + for (let i = 0; i < 20_000; i++) { + payload += `line-${i} plain output\n` + if (payload.length > 64_000) { + vt.feed(payload) + payload = '' + } + } + if (payload) vt.feed(payload) + const elapsed = Date.now() - started + const lines = vt.getLines() + expect(lines.length).toBeLessThanOrEqual(10_000) + expect(lines[lines.length - 1]).toContain('line-19999') + // Generous CI budget — catches pathological O(n²) regressions. + expect(elapsed).toBeLessThan(5_000) + }) + + it('tracks unsupported sequences and exposes low confidence', () => { + const vt = new VirtualTerminal() + // Feed many unknown CSI finals with enough payload to cross the threshold. + let chunk = 'hello\n' + for (let i = 0; i < 20; i++) { + chunk += `\x1b[${i}Zunknown\n` + } + chunk = chunk.repeat(8) + vt.feed(chunk) + expect(vt.getParseStats().unsupportedSequenceCount).toBeGreaterThanOrEqual(12) + expect(vt.getParseConfidence()).toBe('low') + expect(vt.getRawLines().length).toBeGreaterThan(0) + }) + + it('replay feed is idempotent when historyFed guard is simulated by reset+feed once', () => { + const vt = new VirtualTerminal() + const history = 'one\ntwo\nthree\n' + vt.feed(history) + const first = vt.getLines() + // Double-feed without reset would duplicate — callers must guard. + vt.feed(history) + const duplicated = vt.getLines() + expect(duplicated.length).toBeGreaterThan(first.length) + + vt.reset() + vt.feed(history) + expect(vt.getLines()).toEqual(first) + }) +}) diff --git a/__tests__/unit/services/virtual-terminal.test.ts b/__tests__/unit/services/virtual-terminal.test.ts index f12b6043..0bc4d05c 100644 --- a/__tests__/unit/services/virtual-terminal.test.ts +++ b/__tests__/unit/services/virtual-terminal.test.ts @@ -357,6 +357,38 @@ describe('VirtualTerminal – Claude Code TUI chrome filtering', () => { expect(feedAndGet('● high')).toEqual([]) }) + // Claude only repaints the spinner when it has other output to draw, so the + // elapsed number freezes at its last painted value and reads as a hung + // session. Elapsed time is surfaced natively from the session detail instead. + it('filters the spinner line carrying the live counter', () => { + expect(feedAndGet('✽ Swirling… (56s · ↑ 3.4k tokens)')).toEqual([]) + expect(feedAndGet('✽ Blanching… (15s · ↓ 271 tokens)')).toEqual([]) + expect(feedAndGet('Swirling… (5s)')).toEqual([]) + }) + + it('filters completed-turn timers', () => { + expect(feedAndGet('✻ Crunched for 3m 17s')).toEqual([]) + expect(feedAndGet('✽ Cogitated for 13s')).toEqual([]) + }) + + it('keeps real output that merely looks like a spinner line', () => { + const keep = [ + 'Testing… (this is real output)', + 'Refactoring… (see notes below)', + 'Building the project for 10s of users', + ] + for (const line of keep) { + expect(feedAndGet(line)).toEqual([line]) + } + }) + + it('filters the model status line without a minor version', () => { + // "Sonnet 5" has no ".x" part, and the separator is a box-drawing pipe. + expect(feedAndGet('Sonnet 5 │ ~/dev/ai-tools/tb-streamer')).toEqual([]) + expect(feedAndGet('Sonnet 5 [Check local streamer session timeout limits]')).toEqual([]) + expect(feedAndGet('Opus is a great model')).toEqual(['Opus is a great model']) + }) + it('filters hotkey hints', () => { expect(feedAndGet('some text (shift+tab to cycle)')).toEqual([]) expect(feedAndGet('(ctrl+o to expand)')).toEqual([]) @@ -529,3 +561,21 @@ describe('VirtualTerminal – realistic Claude Code PTY data', () => { expect(feedAndGet('line1\r\nline2\r\nline3')).toEqual(['line1', 'line2', 'line3']) }) }) + +// ── Scrollback cap ─────────────────────────────────────────────────────────── +describe('VirtualTerminal – scrollback cap', () => { + const MAX_ROWS = 10_000 + + it('bounds the grid for an append-only stream and keeps the newest lines', () => { + const vt = new VirtualTerminal() + const total = MAX_ROWS + 5000 + // Append-only feed (no screen clears) — the unbounded-growth case. + for (let i = 0; i < total; i++) vt.feed(`line ${i}\n`) + + const lines = vt.getLines() + expect(lines.length).toBeLessThanOrEqual(MAX_ROWS) + // The most recent line survives; the oldest is trimmed away. + expect(lines).toContain(`line ${total - 1}`) + expect(lines).not.toContain('line 0') + }) +}) diff --git a/__tests__/unit/services/ws-client.test.ts b/__tests__/unit/services/ws-client.test.ts index 3270813c..d5d67c8b 100644 --- a/__tests__/unit/services/ws-client.test.ts +++ b/__tests__/unit/services/ws-client.test.ts @@ -122,6 +122,40 @@ describe('WSClient – message handling', () => { unsub() }) + it('dispatches cache_alert to registered handler', () => { + const handler = jest.fn() + const unsub = wsClient.on('cache_alert', handler) + + wsClient.connect('http://test.local', 'key') + mockSocket.onopen!() + const msg = { + type: 'cache_alert', + fingerprint: 'fp1', + severity: 'high', + missingCount: 3, + totalRows: 10, + detectedAt: '2026-07-18T00:00:00.000Z', + sample: [{ id: 'a', title: 'Session A' }], + } + mockSocket.onmessage!({ data: JSON.stringify(msg) }) + + expect(handler).toHaveBeenCalledWith(msg) + unsub() + }) + + it('dispatches cache_alert_resolved to registered handler', () => { + const handler = jest.fn() + const unsub = wsClient.on('cache_alert_resolved', handler) + + wsClient.connect('http://test.local', 'key') + mockSocket.onopen!() + const msg = { type: 'cache_alert_resolved', fingerprint: 'fp1', action: 'ignore' } + mockSocket.onmessage!({ data: JSON.stringify(msg) }) + + expect(handler).toHaveBeenCalledWith(msg) + unsub() + }) + it('silently ignores invalid JSON', () => { const handler = jest.fn() const unsub = wsClient.on('session_update', handler) diff --git a/__tests__/unit/stores/servers.test.ts b/__tests__/unit/stores/servers.test.ts index 098a4d51..b6910d43 100644 --- a/__tests__/unit/stores/servers.test.ts +++ b/__tests__/unit/stores/servers.test.ts @@ -36,6 +36,7 @@ beforeEach(() => { activeServerIds: [], displayedServerIds: [], isLoading: false, + cacheAlert: {}, }) jest.clearAllMocks() }) @@ -223,3 +224,64 @@ describe('reorderServers', () => { ) }) }) + +// ── cacheAlert ────────────────────────────────────────────────────────────── + +describe('setCacheAlert / clearCacheAlert', () => { + const alert = { + fingerprint: 'fp1', + severity: 'high' as const, + detectedAt: '2026-07-18T00:00:00.000Z', + missingCount: 3, + totalRows: 10, + } + + it('setCacheAlert stores the alert wholesale', () => { + const server = seedServer() + useServersStore.getState().setCacheAlert(server.id, alert) + expect(useServersStore.getState().cacheAlert[server.id]).toEqual(alert) + }) + + it('setCacheAlert replaces a previous alert for the same server', () => { + const server = seedServer() + useServersStore.getState().setCacheAlert(server.id, alert) + const updated = { ...alert, fingerprint: 'fp2', missingCount: 5 } + useServersStore.getState().setCacheAlert(server.id, updated) + expect(useServersStore.getState().cacheAlert[server.id]).toEqual(updated) + }) + + it('clearCacheAlert clears when the fingerprint matches', () => { + const server = seedServer() + useServersStore.getState().setCacheAlert(server.id, alert) + useServersStore.getState().clearCacheAlert(server.id, 'fp1') + expect(useServersStore.getState().cacheAlert[server.id]).toBeNull() + }) + + it('clearCacheAlert is a no-op when the fingerprint does not match (stale resolve racing a newer alert)', () => { + const server = seedServer() + const newerAlert = { ...alert, fingerprint: 'fp2' } + useServersStore.getState().setCacheAlert(server.id, newerAlert) + useServersStore.getState().clearCacheAlert(server.id, 'fp1') + expect(useServersStore.getState().cacheAlert[server.id]).toEqual(newerAlert) + }) + + it('clearCacheAlert is a no-op when there is no pending alert', () => { + const server = seedServer() + expect(() => useServersStore.getState().clearCacheAlert(server.id, 'fp1')).not.toThrow() + expect(useServersStore.getState().cacheAlert[server.id]).toBeUndefined() + }) + + it('setConnected(false) clears the alert for a disconnected server', () => { + const server = seedServer({ isConnected: true }) + useServersStore.getState().setCacheAlert(server.id, alert) + useServersStore.getState().setConnected(server.id, false) + expect(useServersStore.getState().cacheAlert[server.id]).toBeNull() + }) + + it('setConnected(true) does not touch an existing alert', () => { + const server = seedServer({ isConnected: false }) + useServersStore.getState().setCacheAlert(server.id, alert) + useServersStore.getState().setConnected(server.id, true) + expect(useServersStore.getState().cacheAlert[server.id]).toEqual(alert) + }) +}) diff --git a/__tests__/unit/stores/sessions.test.ts b/__tests__/unit/stores/sessions.test.ts index 83e92ac7..f6fa822b 100644 --- a/__tests__/unit/stores/sessions.test.ts +++ b/__tests__/unit/stores/sessions.test.ts @@ -63,4 +63,14 @@ describe('SessionsStore – prompt queue', () => { expect(useSessionsStore.getState().promptQueues[KEY]).toHaveLength(1) expect(useSessionsStore.getState().promptQueues[KEY_2]).toHaveLength(1) }) + + it('clears only queues belonging to one server', () => { + useSessionsStore.getState().addToQueue(SERVER, SESSION, makePrompt('p1')) + useSessionsStore.getState().addToQueue('srv_other', SESSION, makePrompt('p2')) + + useSessionsStore.getState().clearServer(SERVER) + + expect(useSessionsStore.getState().promptQueues[KEY]).toBeUndefined() + expect(useSessionsStore.getState().promptQueues[`srv_other::${SESSION}`]).toHaveLength(1) + }) }) diff --git a/__tests__/unit/types/backup.test.ts b/__tests__/unit/types/backup.test.ts new file mode 100644 index 00000000..a5b94365 --- /dev/null +++ b/__tests__/unit/types/backup.test.ts @@ -0,0 +1,86 @@ +import { + parseBackupArchive, + parseRestoreConflictBody, + parseRestoreResponse, +} from '@/types/backup' + +const sampleArchive = { + manifest: { + formatVersion: 1, + createdAt: '2026-07-24T00:00:00.000Z', + streamerVersion: '1.33.0', + sourceHost: 'mac.local', + includesSecrets: false, + counts: { projects: 1 }, + }, + projects: [ + { + id: 'proj-1', + path: '/Users/a/code/app', + name: 'app', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], +} + +describe('parseBackupArchive', () => { + it('parses a valid archive', () => { + expect(parseBackupArchive(sampleArchive)).toEqual(sampleArchive) + }) + + it('rejects bad shapes', () => { + expect(parseBackupArchive({})).toBeNull() + expect(parseBackupArchive({ ...sampleArchive, projects: [{ id: 1 }] })).toBeNull() + }) +}) + +describe('parseRestoreResponse', () => { + it('parses dry-run', () => { + const body = { + applied: false, + summary: { create: 1, update: 0, conflict: 0 }, + plan: { + create: sampleArchive.projects, + update: [], + conflict: [], + }, + } + expect(parseRestoreResponse(body)).toEqual(body) + }) + + it('parses applied', () => { + expect( + parseRestoreResponse({ + applied: true, + summary: { create: 1, update: 0, conflict: 0 }, + appliedCount: 1, + }), + ).toEqual({ + applied: true, + summary: { create: 1, update: 0, conflict: 0 }, + appliedCount: 1, + }) + }) +}) + +describe('parseRestoreConflictBody', () => { + it('parses 409 payload', () => { + const body = { + error: 'Restore has unresolved conflicts', + code: 'RESTORE_CONFLICT', + summary: { create: 0, update: 0, conflict: 1 }, + plan: { + create: [], + update: [], + conflict: [ + { + incoming: sampleArchive.projects[0], + existingId: 'other', + }, + ], + }, + } + expect(parseRestoreConflictBody(body)?.message).toBe('Restore has unresolved conflicts') + }) +}) diff --git a/__tests__/unit/types/devices.test.ts b/__tests__/unit/types/devices.test.ts new file mode 100644 index 00000000..3706540f --- /dev/null +++ b/__tests__/unit/types/devices.test.ts @@ -0,0 +1,67 @@ +import { + deviceHasCapability, + parseDeviceRevokeResponse, + parseDevicesResponse, +} from '@/types/devices' + +describe('parseDevicesResponse', () => { + it('parses a valid payload', () => { + const parsed = parseDevicesResponse({ + available: true, + devices: [ + { + deviceId: 'dev-1', + name: 'iPhone', + capabilities: ['history:read', 'session:control', 'bogus'], + createdAt: 1, + lastSeenAt: 2, + revokedAt: null, + }, + ], + }) + expect(parsed).toEqual({ + available: true, + devices: [ + { + deviceId: 'dev-1', + name: 'iPhone', + capabilities: ['history:read', 'session:control'], + createdAt: 1, + lastSeenAt: 2, + revokedAt: null, + }, + ], + }) + }) + + it('rejects malformed payloads', () => { + expect(parseDevicesResponse({})).toBeNull() + expect(parseDevicesResponse({ available: true, devices: 'x' })).toBeNull() + }) +}) + +describe('parseDeviceRevokeResponse', () => { + it('parses ok + alreadyRevoked', () => { + expect(parseDeviceRevokeResponse({ ok: true, alreadyRevoked: false })).toEqual({ + ok: true, + alreadyRevoked: false, + }) + }) + + it('rejects missing fields', () => { + expect(parseDeviceRevokeResponse({ ok: true })).toBeNull() + }) +}) + +describe('deviceHasCapability', () => { + it('treats missing capabilities as full owner access', () => { + expect(deviceHasCapability(undefined, 'session:control')).toBe(true) + expect(deviceHasCapability([], 'session:control')).toBe(true) + }) + + it('checks listed capabilities and admin', () => { + expect(deviceHasCapability(['history:read'], 'session:control')).toBe(false) + expect(deviceHasCapability(['admin'], 'session:control')).toBe(true) + expect(deviceHasCapability(['session:control'], 'session:control')).toBe(true) + }) +}) diff --git a/__tests__/unit/types/provider-health.test.ts b/__tests__/unit/types/provider-health.test.ts new file mode 100644 index 00000000..0d998806 --- /dev/null +++ b/__tests__/unit/types/provider-health.test.ts @@ -0,0 +1,68 @@ +import { + parseProvidersResponse, + findProviderHealth, + GENERIC_TERMINAL_CAPABILITIES, +} from '@/types/provider-health' + +describe('parseProvidersResponse', () => { + const valid = { + providers: [ + { + name: 'claude-code', + available: true, + version: '2.1.214', + verifiedAgainst: { captured: ['2.1.214'], min: '2.1.0' }, + capabilities: { + freshSessionId: 'explicit', + resume: 'native', + systemPrompt: 'flag', + structuredQuestions: true, + permissionGates: true, + liveControl: true, + }, + warnings: [], + }, + { + name: 'codex-cli', + available: false, + version: null, + verifiedAgainst: { captured: ['0.140.0-alpha.19'], min: '0.140.0' }, + capabilities: { + freshSessionId: 'late-bound', + resume: 'native', + systemPrompt: 'positional', + structuredQuestions: false, + permissionGates: true, + liveControl: true, + }, + warnings: [ + { + code: 'provider_not_found', + message: 'codex-cli could not be located.', + }, + ], + }, + ], + } + + it('parses the providers envelope', () => { + const parsed = parseProvidersResponse(valid) + expect(parsed?.providers).toHaveLength(2) + expect(findProviderHealth(parsed?.providers, 'codex-cli')?.available).toBe(false) + expect(findProviderHealth(parsed?.providers, 'codex-cli')?.warnings[0].code).toBe( + 'provider_not_found', + ) + }) + + it('rejects unknown provider names', () => { + const parsed = parseProvidersResponse({ + providers: [{ ...valid.providers[0], name: 'mystery-cli' }], + }) + expect(parsed?.providers).toHaveLength(0) + }) + + it('exports generic-terminal fallback capabilities', () => { + expect(GENERIC_TERMINAL_CAPABILITIES.structuredQuestions).toBe(false) + expect(GENERIC_TERMINAL_CAPABILITIES.resume).toBe('unsupported') + }) +}) diff --git a/__tests__/unit/utils/mergeLiveMessages.test.ts b/__tests__/unit/utils/mergeLiveMessages.test.ts new file mode 100644 index 00000000..1893df6b --- /dev/null +++ b/__tests__/unit/utils/mergeLiveMessages.test.ts @@ -0,0 +1,80 @@ +import { mergeLiveMessages } from '@/utils/mergeLiveMessages' +import type { Message } from '@/types/api' + +function historical(index: number, uuid: string, text: string): Message { + return { + id: `conv-${index}`, + uuid, + messageIndex: index, + role: index % 2 === 0 ? 'user' : 'assistant', + content: [{ type: 'text', text }], + timestamp: `2026-07-19T10:00:0${index}Z`, + is_sidechain: false, + parent_uuid: null, + } +} + +// A live (WS-parsed) message keys its id off the uuid, not the conversation +// index — mirroring parseLineToMessage in useConversationStream. +function live(uuid: string, text: string, role: 'user' | 'assistant' = 'assistant'): Message { + return { + id: uuid, + uuid, + role, + content: [{ type: 'text', text }], + timestamp: `2026-07-19T11:00:00Z`, + is_sidechain: false, + parent_uuid: null, + } +} + +const texts = (msgs: Message[]) => + msgs.map((m) => (m.content.find((b) => b.type === 'text') as { text: string } | undefined)?.text) + +describe('mergeLiveMessages', () => { + it('appends live messages after REST history, preserving file/arrival order', () => { + const hist = [historical(0, 'h0', 'first'), historical(1, 'h1', 'second')] + const liveMsgs = [live('L0', 'live-third'), live('L1', 'live-fourth')] + expect(texts(mergeLiveMessages(hist, liveMsgs))).toEqual([ + 'first', + 'second', + 'live-third', + 'live-fourth', + ]) + }) + + it('returns the same array reference-content when there are no live messages', () => { + const hist = [historical(0, 'h0', 'first')] + expect(texts(mergeLiveMessages(hist, []))).toEqual(['first']) + }) + + it('drops a live message whose uuid already landed in REST history (WS + drain race)', () => { + // The same turn arrives once over WS and again via the REST delta drain. + // History carries the authoritative uuid, so the WS copy is deduped away. + const hist = [historical(0, 'h0', 'hello'), historical(1, 'dup-uuid', 'echoed turn')] + const liveMsgs = [live('dup-uuid', 'echoed turn')] + const merged = mergeLiveMessages(hist, liveMsgs) + expect(texts(merged)).toEqual(['hello', 'echoed turn']) + expect(merged.filter((m) => m.uuid === 'dup-uuid')).toHaveLength(1) + }) + + it('dedups uuid-less messages that collide on fallback id', () => { + const a: Message = { ...live('', 'x'), id: 'same', uuid: null } + const b: Message = { ...live('', 'x'), id: 'same', uuid: null } + expect(mergeLiveMessages([], [a, b])).toHaveLength(1) + }) + + it('splices the middle segment between history and live (optimistic bubbles)', () => { + const hist = [historical(0, 'h0', 'first')] + const middle = [{ ...live('opt', 'typing…', 'user'), id: 'optimistic-1', uuid: null }] + const liveMsgs = [live('L0', 'reply')] + expect(texts(mergeLiveMessages(hist, liveMsgs, middle))).toEqual(['first', 'typing…', 'reply']) + }) + + it('keeps history → middle → live order even when timestamps are out of order', () => { + const hist = [{ ...historical(0, 'h0', 'first'), timestamp: '2026-07-24T00:00:02.000Z' }] + const middle = [{ ...live('opt', 'typing…', 'user'), id: 'optimistic-1', uuid: null, timestamp: '2026-07-24T00:00:01.000Z' }] + const liveMsgs = [{ ...live('L0', 'reply'), timestamp: '2026-07-24T00:00:00.000Z' }] + expect(texts(mergeLiveMessages(hist, liveMsgs, middle))).toEqual(['first', 'typing…', 'reply']) + }) +}) diff --git a/app/_layout.tsx b/app/_layout.tsx index 68ecb7e3..e9fdfa65 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -23,12 +23,12 @@ import { SafeAreaProvider } from 'react-native-safe-area-context' import { KeyboardProvider } from 'react-native-keyboard-controller' import * as Notifications from 'expo-notifications' import { useServersStore } from '@/stores/servers' -import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' import { useSettingsStore } from '@/stores/settings' import { useSessionNamesStore } from '@/stores/sessionNames' import { useQuickAccessStore } from '@/stores/quickAccess' import { wsManager } from '@/services/ws-client' -import type { Session, MultiSession } from '@/types/api' +import { applySessionUpdateToEagerCache, refreshEagerConversations } from '@/lib/eagerCacheSync' +import type { Session } from '@/types/api' import { registerPushTokenForAll } from '@/services/push' import { SplashAnimation } from '@/components/SplashAnimation' import { SlowQueryBanner } from '@/components/SlowQueryBanner' @@ -42,6 +42,7 @@ import { installClientLogCapture, clientLog } from '@/lib/clientLog' import { shouldSkipAutoNav } from '@/lib/sessionNavGuard' import { useTranslation } from 'react-i18next' import { RootErrorBoundary } from '@/components/RootErrorBoundary' +import { CacheAlertSync } from '@/components/servers/CacheAlertSync' import { useCrashReportingSync } from '@/hooks/useCrashReportingSync' import { wrap as sentryWrap } from '@/services/sentry' import { recordDiagnosticEvent } from '@/services/diagnostic-events' @@ -67,9 +68,9 @@ function AuthGate({ children }: { children: React.ReactNode }) { const hydrateSessionNames = useSessionNamesStore((s) => s.hydrate) const hydrateQuickAccess = useQuickAccessStore((s) => s.hydrate) const setConnected = useServersStore((s) => s.setConnected) - const setCacheReady = useServersStore((s) => s.setCacheReady) - const recordFetchSuccess = useServerFetchStatusStore((s) => s.recordSuccess) const setScanProgress = useServersStore((s) => s.setScanProgress) + const setCacheAlert = useServersStore((s) => s.setCacheAlert) + const clearCacheAlert = useServersStore((s) => s.clearCacheAlert) useEffect(() => { hydrateSettings().then(() => { @@ -128,19 +129,17 @@ function AuthGate({ children }: { children: React.ReactNode }) { queryClient.setQueryData(key, (prev) => prev ? { ...prev, ...msg.session } : (msg.session as Session), ) - // Patch the eager paginated sessions cache (home-screen list) in place - // so the row's status flips without an HTTP refetch. - queryClient.setQueriesData( - { queryKey: ['sessions-eager'] }, - (old) => - Array.isArray(old) - ? old.map((s) => - s.serverId === msg.serverId && s.id === msg.session.id - ? { ...s, ...msg.session } - : s, - ) - : old, - ) + // Patch the eager paginated sessions cache (home-screen list) in place so + // the row's status flips without an HTTP refetch; if the session isn't in + // the list yet (e.g. a newly-alive external session), invalidate so it + // appears without a manual pull-to-refresh. + applySessionUpdateToEagerCache(queryClient, msg.serverId, msg.session) + }) + // External-session liveness ping: a conversation's JSONL grew without a PTY + // the streamer owns. Refresh the eager conversations list so its row updates. + const unsubConvUpdated = wsManager.onAll('conversation_updated', (msg) => { + if (msg.type !== 'conversation_updated') return + refreshEagerConversations(queryClient) }) const unsubReady = wsManager.onAll('session_ready', (msg) => { if (msg.type !== 'session_ready') return @@ -171,27 +170,46 @@ function AuthGate({ children }: { children: React.ReactNode }) { }) const unsubCacheReady = wsManager.onAll('cache_ready', (msg) => { if (msg.type !== 'cache_ready') return - setCacheReady(msg.serverId) - recordFetchSuccess(msg.serverId) + void queryClient.invalidateQueries({ queryKey: ['sessions-eager'] }) + void queryClient.invalidateQueries({ queryKey: ['sessions'] }) + void queryClient.invalidateQueries({ queryKey: ['conversations-eager'] }) + void queryClient.invalidateQueries({ queryKey: ['conversations'] }) }) const unsubScanProgress = wsManager.onAll('scan_progress', (msg) => { if (msg.type !== 'scan_progress') return setScanProgress(msg.serverId, msg.scanned, msg.total) }) + const unsubCacheAlert = wsManager.onAll('cache_alert', (msg) => { + if (msg.type !== 'cache_alert') return + setCacheAlert(msg.serverId, { + fingerprint: msg.fingerprint, + severity: msg.severity, + detectedAt: msg.detectedAt, + missingCount: msg.missingCount, + totalRows: msg.totalRows, + }) + }) + const unsubCacheAlertResolved = wsManager.onAll('cache_alert_resolved', (msg) => { + if (msg.type !== 'cache_alert_resolved') return + clearCacheAlert(msg.serverId, msg.fingerprint) + }) // Register push tokens for all servers registerPushTokenForAll(activeServerIds).catch(() => {}) return () => { unsubUpdate() + unsubConvUpdated() unsubReady() unsubStatus() unsubCacheReady() unsubScanProgress() + unsubCacheAlert() + unsubCacheAlertResolved() wsManager.disconnectAll() } - // router from expo-router is a stable singleton; setConnected/setCacheReady - // are stable Zustand setters. Wiring is intentionally scoped to activeServerIds changes. + // router from expo-router is a stable singleton; setConnected is a stable + // Zustand setter. Wiring is intentionally scoped to activeServerIds changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeServerIds]) @@ -210,7 +228,12 @@ function AuthGate({ children }: { children: React.ReactNode }) { return () => sub.remove() }, [router]) - return <>{children} + return ( + <> + + {children} + + ) } function BiometricLockGate({ children }: { children: React.ReactNode }) { @@ -301,6 +324,22 @@ export function ThemedStack({ router }: { router: ReturnType } name="diagnostics" options={{ title: i18n.t('feedback:diagnostics.screenTitle'), headerShown: true }} /> + + + + { diff --git a/app/backup-restore.tsx b/app/backup-restore.tsx new file mode 100644 index 00000000..c8b81388 --- /dev/null +++ b/app/backup-restore.tsx @@ -0,0 +1,495 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { + View, + Text, + TouchableOpacity, + ScrollView, + StyleSheet, + ActivityIndicator, + Share, + TextInput, + Alert, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import * as Clipboard from 'expo-clipboard' +import { useTranslation } from 'react-i18next' +import { Archive, ArrowsClockwise, Warning } from 'phosphor-react-native' +import { useTheme, useIsGlass } from '@/contexts/ThemeContext' +import { GlassFill } from '@/components/ui/GlassFill' +import { type Theme, font, radius, spacing } from '@/constants/theme' +import { MIN_TOUCH_TARGET } from '@/constants/a11y' +import { useServersStore } from '@/stores/servers' +import { useBackupRestore } from '@/hooks/useBackup' +import { archiveToShareText, exportBackup, RestoreConflictError } from '@/services/backup' +import { + parseBackupArchive, + type BackupArchive, + type RestoreDryRunResponse, + type RestorePlan, +} from '@/types/backup' +import { NetworkError } from '@/services/api-client' + +export default function BackupRestoreScreen() { + const { t } = useTranslation(['settings', 'common']) + const theme = useTheme() + const isGlass = useIsGlass() + const s = useMemo(() => styles(theme), [theme]) + + const servers = useServersStore((st) => st.servers) + const activeServerIds = useServersStore((st) => st.activeServerIds) + const serverIds = useMemo( + () => activeServerIds.filter((id) => !!servers[id]), + [activeServerIds, servers], + ) + + const [pickedId, setPickedId] = useState(null) + const selectedId = + pickedId && serverIds.includes(pickedId) ? pickedId : (serverIds[0] ?? null) + + const restore = useBackupRestore(selectedId) + + const [exporting, setExporting] = useState(false) + const [exported, setExported] = useState(null) + const [pasteText, setPasteText] = useState('') + const [pathFrom, setPathFrom] = useState('') + const [pathTo, setPathTo] = useState('') + const [dryRun, setDryRun] = useState(null) + const [actionMsg, setActionMsg] = useState(null) + const [actionError, setActionError] = useState(null) + + const pathMap = useMemo(() => { + const from = pathFrom.trim() + const to = pathTo.trim() + if (!from || !to) return undefined + return [{ from, to }] + }, [pathFrom, pathTo]) + + const handleExport = useCallback(async () => { + if (!selectedId) return + setExporting(true) + setActionError(null) + setActionMsg(null) + try { + const archive = await exportBackup(selectedId) + setExported(archive) + setActionMsg(t('backup.exported', { count: archive.manifest.counts.projects })) + } catch (err) { + setActionError(err instanceof Error ? err.message : t('backup.exportFailed')) + } finally { + setExporting(false) + } + }, [selectedId, t]) + + const handleShareExport = useCallback(async () => { + if (!exported) return + try { + await Share.share({ message: archiveToShareText(exported) }) + } catch { + setActionError(t('backup.shareFailed')) + } + }, [exported, t]) + + const handleCopyExport = useCallback(async () => { + if (!exported) return + try { + await Clipboard.setStringAsync(archiveToShareText(exported)) + setActionMsg(t('backup.copied')) + } catch { + setActionError(t('backup.copyFailed')) + } + }, [exported, t]) + + const resolveArchiveFromPaste = useCallback((): BackupArchive | null => { + const trimmed = pasteText.trim() + if (!trimmed) { + setActionError(t('backup.pasteRequired')) + return null + } + let parsedJson: object + try { + parsedJson = JSON.parse(trimmed) as object + } catch { + setActionError(t('backup.invalidJson')) + return null + } + const archive = parseBackupArchive(parsedJson) + if (!archive) { + setActionError(t('backup.invalidArchive')) + return null + } + return archive + }, [pasteText, t]) + + const handleDryRun = useCallback(async () => { + if (!selectedId) return + const archive = resolveArchiveFromPaste() + if (!archive) return + setActionError(null) + setActionMsg(null) + setDryRun(null) + try { + const result = await restore.mutateAsync({ archive, apply: false, pathMap }) + if (result.applied) { + setActionError(t('backup.unexpectedApply')) + return + } + setDryRun(result) + setActionMsg(t('backup.dryRunReady')) + } catch (err) { + setActionError(err instanceof Error ? err.message : t('backup.dryRunFailed')) + } + }, [pathMap, resolveArchiveFromPaste, restore, selectedId, t]) + + const handleApply = useCallback(() => { + if (!selectedId || !dryRun) return + const archive = resolveArchiveFromPaste() + if (!archive) return + + Alert.alert(t('backup.applyTitle'), t('backup.applyBody'), [ + { text: t('common:button.cancel'), style: 'cancel' }, + { + text: t('backup.applyConfirm'), + style: 'destructive', + onPress: () => { + void (async () => { + setActionError(null) + setActionMsg(null) + try { + const result = await restore.mutateAsync({ archive, apply: true, pathMap }) + if (!result.applied) { + setActionError(t('backup.applyFailed')) + return + } + setActionMsg(t('backup.applied', { count: result.appliedCount })) + setDryRun(null) + } catch (err) { + if (err instanceof RestoreConflictError) { + setDryRun({ applied: false, summary: err.summary, plan: err.plan }) + setActionError(t('backup.conflict')) + return + } + if (err instanceof NetworkError && err.code === 'RESTORE_CONFLICT') { + setActionError(t('backup.conflict')) + return + } + setActionError(err instanceof Error ? err.message : t('backup.applyFailed')) + } + })() + }, + }, + ]) + }, [dryRun, pathMap, resolveArchiveFromPaste, restore, selectedId, t]) + + if (serverIds.length === 0) { + return ( + + + {t('backup.emptyTitle')} + {t('backup.emptyBody')} + + + ) + } + + return ( + + + + + {t('backup.heading')} + + {t('backup.subtitle')} + + + + + + {t('backup.metadataOnly')} + + + + {serverIds.length > 1 ? ( + + {serverIds.map((id) => { + const label = servers[id]?.label?.trim() || servers[id]?.url || id + const selectedChip = id === selectedId + return ( + { + setPickedId(id) + setExported(null) + setDryRun(null) + setActionMsg(null) + setActionError(null) + }} + accessibilityRole="button" + accessibilityState={{ selected: selectedChip }} + testID={`backup-chip-${id}`} + > + + {label} + + + ) + })} + + ) : null} + + {t('backup.exportSection')} + + void handleExport()} + disabled={exporting} + accessibilityRole="button" + testID="backup-export" + > + {exporting ? ( + + ) : ( + + )} + + {exporting ? t('backup.exporting') : t('backup.export')} + + + {exported ? ( + <> + void handleCopyExport()} testID="backup-copy"> + {t('backup.copy')} + + void handleShareExport()} testID="backup-share"> + {t('backup.share')} + + + ) : null} + + + {exported ? ( + + + {t('backup.manifestTitle')} + + {t('backup.manifestHost', { host: exported.manifest.sourceHost })} + + + {t('backup.manifestVersion', { version: exported.manifest.streamerVersion })} + + + {t('backup.manifestProjects', { count: exported.manifest.counts.projects })} + + + {t('backup.manifestCreated', { at: exported.manifest.createdAt })} + + + ) : null} + + {t('backup.restoreSection')} + {t('backup.pasteHint')} + + + {t('backup.pathMapHint')} + + + + + void handleDryRun()} + disabled={restore.isPending} + accessibilityRole="button" + testID="backup-dry-run" + > + + {restore.isPending ? t('backup.planning') : t('backup.dryRun')} + + + {dryRun ? ( + 0} + accessibilityRole="button" + testID="backup-apply" + > + 0 && s.disabledText]}> + {t('backup.apply')} + + + ) : null} + + + {dryRun ? : null} + + {actionMsg ? {actionMsg} : null} + {actionError ? ( + + {actionError} + + ) : null} + + + ) +} + +function PlanCard({ + plan, + summary, + isGlass, + s, +}: { + plan: RestorePlan + summary: { create: number; update: number; conflict: number } + isGlass: boolean + s: ReturnType +}) { + const { t } = useTranslation('settings') + return ( + + + {t('backup.planTitle')} + {t('backup.planSummary', summary)} + {summary.conflict > 0 ? ( + {t('backup.conflictDetail')} + ) : null} + {plan.conflict.slice(0, 8).map((c) => ( + + {t('backup.conflictRow', { + path: c.incoming.path, + incoming: c.incoming.id.slice(0, 8), + existing: c.existingId.slice(0, 8), + })} + + ))} + {plan.create.slice(0, 5).map((p) => ( + + {t('backup.createRow', { name: p.name ?? p.path })} + + ))} + {plan.update.slice(0, 5).map((p) => ( + + {t('backup.updateRow', { name: p.name ?? p.path })} + + ))} + + ) +} + +function styles(theme: Theme) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.bg.primary }, + content: { padding: spacing.md, gap: spacing.sm, paddingBottom: spacing.xxl }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + padding: spacing.lg, + }, + emptyTitle: { color: theme.text.primary, fontSize: font.lg, fontWeight: '600', textAlign: 'center' }, + emptyBody: { color: theme.text.secondary, fontSize: font.base, textAlign: 'center', lineHeight: 21 }, + headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.sm }, + heading: { color: theme.text.primary, fontSize: font.xl, fontWeight: '700' }, + subtitle: { color: theme.text.secondary, fontSize: font.base, lineHeight: 21 }, + chips: { gap: spacing.xs, paddingVertical: spacing.xs }, + chip: { + paddingHorizontal: spacing.md, + minHeight: MIN_TOUCH_TARGET, + borderRadius: radius.full, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bg.card, + justifyContent: 'center', + maxWidth: 220, + }, + chipSelected: { borderColor: theme.text.accent, backgroundColor: theme.bg.secondary }, + chipText: { color: theme.text.secondary, fontSize: font.sm }, + chipTextSelected: { color: theme.text.accent, fontWeight: '600' }, + sectionLabel: { + color: theme.text.secondary, + fontSize: font.xs, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, + marginTop: spacing.sm, + }, + helper: { color: theme.text.secondary, fontSize: font.sm, lineHeight: 18 }, + toolbar: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + toolbarBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + minHeight: MIN_TOUCH_TARGET, + paddingHorizontal: spacing.sm, + }, + toolbarBtnText: { color: theme.text.accent, fontSize: font.sm, fontWeight: '600' }, + disabledText: { opacity: 0.4 }, + card: { + backgroundColor: theme.bg.card, + borderRadius: radius.md, + borderWidth: 1, + borderColor: theme.border, + overflow: 'hidden', + padding: spacing.md, + gap: spacing.xs, + }, + cardGlass: { backgroundColor: 'transparent' }, + cardTitle: { color: theme.text.primary, fontSize: font.base, fontWeight: '600' }, + meta: { color: theme.text.secondary, fontSize: font.xs, lineHeight: 16 }, + warnRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm }, + warnText: { flex: 1, color: theme.text.warning, fontSize: font.sm, lineHeight: 20 }, + pasteInput: { + minHeight: 120, + borderWidth: 1, + borderColor: theme.border, + borderRadius: radius.md, + padding: spacing.sm, + color: theme.text.primary, + fontSize: font.sm, + textAlignVertical: 'top', + backgroundColor: theme.bg.card, + }, + singleInput: { + minHeight: MIN_TOUCH_TARGET, + borderWidth: 1, + borderColor: theme.border, + borderRadius: radius.md, + paddingHorizontal: spacing.sm, + color: theme.text.primary, + fontSize: font.sm, + backgroundColor: theme.bg.card, + }, + actionMsg: { color: theme.text.secondary, fontSize: font.sm }, + errorText: { color: theme.text.danger, fontSize: font.sm, lineHeight: 20 }, + }) +} diff --git a/app/browse.tsx b/app/browse.tsx index b6d876e0..930cf422 100644 --- a/app/browse.tsx +++ b/app/browse.tsx @@ -15,26 +15,25 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { runOnJS } from 'react-native-reanimated' import { FlashList } from '@shopify/flash-list' import { SafeAreaView } from 'react-native-safe-area-context' +import { CaretDown, CaretRight, ClockCounterClockwise } from 'phosphor-react-native' import { useBrowse, useCreateDirectory } from '@/hooks/useBrowse' import { useSessions } from '@/hooks/useSession' import { SkeletonBox } from '@/components/ui/Skeleton' import { EmptyState } from '@/components/ui/EmptyState' import { NetworkError } from '@/services/api-client' import { BrowseSlowBanner } from '@/components/browse/BrowseSlowBanner' +import { RecentDirsModal, type RecentDir } from '@/components/browse/RecentDirsModal' import { useLoadingStateStore } from '@/stores/loading-state' import { font, radius, spacing, brand, type Theme } from '@/constants/theme' import { useTheme, useIsGlass } from '@/contexts/ThemeContext' import { GlassFill } from '@/components/ui/GlassFill' import { CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type ProviderName } from '@/constants/providers' import { clientLog } from '@/lib/clientLog' +import { useProviderHealth } from '@/hooks/useProviderHealth' +import { findProviderHealth } from '@/types/provider-health' const MAX_RECENT_DIRS = 8 - -interface RecentDir { - path: string - name: string - lastUsedAt: string -} +const PREVIEW_RECENT_DIRS = 3 export default function BrowseScreen() { const theme = useTheme() @@ -53,9 +52,20 @@ export default function BrowseScreen() { const [showNewFolder, setShowNewFolder] = useState(false) const [keyboardHeight, setKeyboardHeight] = useState(0) const [isRecentsOpen, setIsRecentsOpen] = useState(true) + const [showAllRecents, setShowAllRecents] = useState(false) const [selectedProvider, setSelectedProvider] = useState(CLAUDE_CODE_PROVIDER) + const { data: providerHealth } = useProviderHealth(serverId) + const selectedHealth = findProviderHealth(providerHealth?.providers, selectedProvider) + const selectedUnavailable = selectedHealth?.available === false + const selectedWarnings = selectedHealth?.warnings ?? [] + const showProviderNotes = + selectedUnavailable || + selectedWarnings.length > 0 || + selectedHealth?.capabilities.structuredQuestions === false || + selectedHealth?.capabilities.liveControl === false const { data: allSessions = [] } = useSessions() + // Newest → oldest by last session start; first hit wins for path dedupe. const recentDirs = useMemo(() => { if (!serverId) { return [] @@ -83,6 +93,8 @@ export default function BrowseScreen() { } return dirs }, [allSessions, serverId]) + const previewRecentDirs = recentDirs.slice(0, PREVIEW_RECENT_DIRS) + const hasMoreRecents = recentDirs.length > PREVIEW_RECENT_DIRS useEffect(() => { const showSub = Keyboard.addListener('keyboardWillShow', (e) => { @@ -233,6 +245,7 @@ export default function BrowseScreen() { (dir: RecentDir) => { if (isStarting) return setIsStarting(true) + setShowAllRecents(false) clientLog.info('browse', 'start from recent pressed', { path: dir.path, serverId }) navigateToStartScreen(dir.path, dir.name) }, @@ -268,7 +281,7 @@ export default function BrowseScreen() { return ( - + {/* Breadcrumbs */} navigateToBreadcrumb(-1)}> @@ -301,18 +314,23 @@ export default function BrowseScreen() { {t('nav.recentDirs', { count: recentDirs.length })} - {isRecentsOpen ? '▾' : '▸'} + {isRecentsOpen ? ( + + ) : ( + + )} {isRecentsOpen ? ( - {recentDirs.map((dir) => ( + {previewRecentDirs.map((dir) => ( handleStartFromRecent(dir)} disabled={isStarting} + testID={`recent-dir-preview-${dir.path}`} > - 🕘 + {dir.name} @@ -321,14 +339,32 @@ export default function BrowseScreen() { {dir.path} - + ))} + {hasMoreRecents ? ( + setShowAllRecents(true)} + accessibilityRole="button" + testID="recent-dirs-display-all" + > + {t('nav.displayAll')} + + ) : null} ) : null} ) : null} + setShowAllRecents(false)} + onSelect={handleStartFromRecent} + disabled={isStarting} + /> + {/* Directory list */} {isLoading ? ( @@ -397,6 +433,8 @@ export default function BrowseScreen() { { value: CODEX_CLI_PROVIDER, label: 'Codex', color: brand.codex }, ]).map((option) => { const selected = selectedProvider === option.value + const health = findProviderHealth(providerHealth?.providers, option.value) + const unavailable = health?.available === false return ( setSelectedProvider(option.value)} accessibilityRole="button" - accessibilityState={{ selected }} + accessibilityState={{ selected, disabled: unavailable }} testID={`start-provider-${option.value}`} > @@ -416,6 +455,7 @@ export default function BrowseScreen() { styles.providerOptionText, selected && styles.providerOptionTextSelected, selected ? { color: option.color } : null, + unavailable && styles.providerOptionTextDisabled, ]} > {option.label} @@ -424,6 +464,31 @@ export default function BrowseScreen() { ) })} + {showProviderNotes ? ( + + {selectedUnavailable ? ( + {t('provider.unavailable')} + ) : null} + {selectedWarnings.map((w) => { + let warningLabel = t('provider.warning.version_unverified') + if (w.code === 'provider_not_found') warningLabel = t('provider.warning.provider_not_found') + else if (w.code === 'version_undetectable') { + warningLabel = t('provider.warning.version_undetectable') + } + return ( + + {warningLabel} + + ) + })} + {selectedHealth && !selectedHealth.capabilities.structuredQuestions ? ( + {t('provider.noStructuredQuestions')} + ) : null} + {selectedHealth && !selectedHealth.capabilities.liveControl ? ( + {t('provider.observeOnly')} + ) : null} + + ) : null} {isStarting ? ( @@ -506,10 +575,6 @@ function makeStyles(theme: Theme) { letterSpacing: 0.5, textTransform: 'uppercase', }, - recentsChevron: { - color: theme.text.secondary, - fontSize: font.sm, - }, recentsList: { paddingVertical: spacing.xs, }, @@ -519,12 +584,9 @@ function makeStyles(theme: Theme) { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, }, - recentIcon: { - fontSize: 16, - marginRight: spacing.md, - }, recentTextWrap: { flex: 1, + marginLeft: spacing.md, }, recentName: { color: theme.text.primary, @@ -535,6 +597,16 @@ function makeStyles(theme: Theme) { fontSize: font.xs, marginTop: 2, }, + displayAllBtn: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + alignItems: 'center', + }, + displayAllText: { + color: theme.text.accent, + fontSize: font.sm, + fontWeight: '600', + }, skeletons: { padding: spacing.lg, }, @@ -561,6 +633,27 @@ function makeStyles(theme: Theme) { providerOptionSelected: { backgroundColor: theme.bg.card, }, + providerOptionDisabled: { + opacity: 0.55, + }, + providerOptionTextDisabled: { + color: theme.text.secondary, + }, + providerWarning: { + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + padding: spacing.sm, + borderRadius: radius.md, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bg.secondary, + gap: 4, + }, + providerWarningText: { + color: theme.text.warning, + fontSize: font.xs, + lineHeight: 16, + }, providerDot: { width: 8, height: 8, diff --git a/app/conversation/[id].tsx b/app/conversation/[id].tsx index bdfb9a7c..0094f116 100644 --- a/app/conversation/[id].tsx +++ b/app/conversation/[id].tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { View, Text, + TextInput, TouchableOpacity, Pressable, StyleSheet, @@ -11,33 +12,46 @@ import { ActivityIndicator, FlatList, Animated, + AppState, type LayoutChangeEvent, type ListRenderItemInfo, } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' -import { ExportIcon, InfoIcon, Star } from 'phosphor-react-native' +import { ExportIcon, InfoIcon, MagnifyingGlass, Star } from 'phosphor-react-native' import { SafeAreaView } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { useQuery } from '@tanstack/react-query' +import { useLocalSearchParams, useRouter, useFocusEffect } from 'expo-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { ProgressBar } from '@/components/ui/ProgressBar' import { MessageSkeletonRow } from '@/components/conversation/MessageSkeletonRow' import { SlowLoadingBanner } from '@/components/conversation/SlowLoadingBanner' import { ConversationHistoryList } from '@/components/conversation/ConversationHistoryList' import { ConversationSearchView } from '@/components/conversation/ConversationSearchView' +import { makeStyles as makeSearchStyles } from '@/components/sessions/SearchStyles' import { useLoadingStateStore } from '@/stores/loading-state' import { useConversation } from '@/hooks/useConversations' +import { useConversationStream } from '@/hooks/useConversationStream' import { useMinDisplayTime } from '@/hooks/useMinDisplayTime' -import { createApiForServer, NotFoundError } from '@/services/api-client' +import { createApiForServer, ConversationBusyError, NotFoundError } from '@/services/api-client' +import { wsManager } from '@/services/ws-client' +import { mergeLiveMessages } from '@/utils/mergeLiveMessages' +import { evictStaleConversationFavorite } from '@/lib/sessionLifecycle' +import { useSessionActions, type ResumeResult } from '@/hooks/useSessionActions' import { useServersStore } from '@/stores/servers' import { brand, font, spacing, type Theme } from '@/constants/theme' import { useTheme } from '@/contexts/ThemeContext' import { InfoModal } from '@/components/shared/InfoModal' +import { LivePauseControl } from '@/components/conversation/LivePauseControl' import { ScreenHeader } from '@/components/shared/ScreenHeader' -import type { Session } from '@/types/api' +import type { Message, Session } from '@/types/api' import { useQuickAccessStore, buildFavoriteId, QUICK_ACCESS_STORAGE_KEY } from '@/stores/quickAccess' const MESSAGE_SKELETON_KEYS = Array.from({ length: 10 }, (_, i) => `msg-sk-${i}`) +// Cadence for the focus-scoped freshness poll (P2.1). The detail endpoint is +// stale-while-revalidate (~2s) and the drain self-throttles (5s canTrigger), so +// a 3s tick converges without a ?refresh=1 or over-fetching. +const LIVE_POLL_INTERVAL_MS = 3000 + interface SearchTargetResponse { query: string message_index: number @@ -51,6 +65,7 @@ export default function ConversationDetailScreen() { const { t } = useTranslation(['conversation', 'common']) const theme = useTheme() const styles = useMemo(() => makeStyles(theme), [theme]) + const searchStyles = useMemo(() => makeSearchStyles(theme), [theme]) const { id, server, search, anchor_index } = useLocalSearchParams<{ id: string server?: string @@ -67,6 +82,34 @@ export default function ConversationDetailScreen() { const anchorParam = typeof anchor_index === 'string' ? Number.parseInt(anchor_index, 10) : NaN const hasAnchorParam = Number.isFinite(anchorParam) + // In-chat search entry: toggles a query bar that writes ?search= on submit. + // Prefills / auto-opens when navigation already carries a search param (Hub). + // Synced during render (same pattern as fetchAnchor below) so we don't need + // an effect that setStates on searchQuery changes. + const [searchBarState, setSearchBarState] = useState<{ + open: boolean + draft: string + syncedQuery: string | undefined + }>({ open: false, draft: '', syncedQuery: undefined }) + if (searchQuery !== searchBarState.syncedQuery) { + setSearchBarState({ + open: searchQuery ? true : searchBarState.open, + draft: searchQuery ?? '', + syncedQuery: searchQuery, + }) + } + const { open: searchOpen, draft: searchDraft } = searchBarState + + const submitInChatSearch = useCallback(() => { + const trimmed = searchDraft.trim() + if (trimmed.length === 0) { + router.setParams({ search: '', anchor_index: '' }) + return + } + // Clear any prior anchor_index so the search-target resolver runs again. + router.setParams({ search: trimmed, anchor_index: '' }) + }, [router, searchDraft]) + // Resolves an active search query to the message to scroll to and highlight. // Skipped when the caller supplied anchor_index directly. A 404 leaves // targetQuery.data undefined — the conversation opens at its normal tail with @@ -133,8 +176,104 @@ export default function ConversationDetailScreen() { isFetchingNewerPage, totalMessages, loadedMessages, + triggerDelta, } = useConversation(serverId, id, { anchorIndex: fetchAnchorIndex, enabled: !isResolvingTarget }) + // P2.2: external-session live push. Phase-1 keys external transcript frames by + // the conversation UUID in the sessionId field, so mounting the stream with + // sessionId === conversationId === id makes the hook's strict-equality filter + // match unchanged. Read-only: liveMessages is merged after REST history, never + // sent back. The subscription stays mounted while paused (tearing it down + // would clear liveMessages and drop live-only bubbles); pausing freezes the + // rendered snapshot instead — see mergedMessages below. + const [livePaused, setLivePaused] = useState(false) + const { liveMessages } = useConversationStream(serverId, id, id) + + // Latched true once a conversation_updated growth push arrives for this + // conversation. Combined at render with live WS frames into `isLive`, which + // drives the pause/resume control's visibility so it only appears on a + // genuinely live session, not on static history. + const [isLiveEvent, setIsLiveEvent] = useState(false) + + // P2.1: while this screen is focused AND the app is foregrounded, poll the + // delta drain so REST history stays fresh against an unmodified server. The + // drain self-throttles (5s canTrigger), so the 3s cadence is intentionally + // finer than the throttle rather than aligned to it. Stops on blur + // (useFocusEffect cleanup) or background. + useFocusEffect( + useCallback(() => { + // Paused: hold the transcript as-is — run no freshness poll at all. + if (livePaused) return + let timer: ReturnType | null = null + const start = () => { + if (timer == null) timer = setInterval(triggerDelta, LIVE_POLL_INTERVAL_MS) + } + const stop = () => { + if (timer != null) { + clearInterval(timer) + timer = null + } + } + if (AppState.currentState === 'active') start() + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') start() + else stop() + }) + return () => { + stop() + sub.remove() + } + }, [triggerDelta, livePaused]), + ) + + // P2.2: the additive conversation_updated push is an extra drain trigger — an + // external writer grew this conversation's JSONL, so pull the delta now + // instead of waiting for the next poll tick. + useEffect(() => { + const client = wsManager.getClient(serverId) + if (!client) return + return client.on('conversation_updated', (msg) => { + if (msg.type !== 'conversation_updated' || msg.conversationId !== id) return + // A growth push means the session is live — surface the pause control even + // while paused (so the user can resume). Only drain when not paused. + setIsLiveEvent(true) + if (!livePaused) triggerDelta() + }) + }, [serverId, id, triggerDelta, livePaused]) + + // Live WS frames landing are also a liveness signal (covers servers that push + // conversation_event(s) without a separate conversation_updated). Latched at + // render — once live, stays live for the screen's life — so no state effect. + const isLive = isLiveEvent || liveMessages.length > 0 + + // Resuming catches the transcript up in one drain — the poll + WS take over again. + const toggleLivePaused = useCallback(() => { + setLivePaused((prev) => { + if (prev) triggerDelta() + return !prev + }) + }, [triggerDelta]) + + // Merge WS-live messages after REST history for the tail view. When nothing is + // streaming this is referentially the same array as conversation.messages, so + // the non-live render path stays byte-identical. + const liveMerged = useMemo(() => { + if (!conversation) return [] + return liveMessages.length > 0 + ? mergeLiveMessages(conversation.messages, liveMessages) + : conversation.messages + }, [conversation, liveMessages]) + + // Pausing freezes the transcript: hold the last live merge and keep showing it + // until the user resumes (nothing is dropped, nothing new appears). The ref is + // only advanced while NOT paused — a render-time snapshot cache, not reactive + // state, so it can't itself schedule a render. + const frozenRef = useRef([]) + // eslint-disable-next-line react-hooks/refs -- render-time freeze snapshot; see note above + if (!livePaused) frozenRef.current = liveMerged + // eslint-disable-next-line react-hooks/refs -- render-time freeze snapshot; see note above + const mergedMessages = livePaused ? frozenRef.current : liveMerged + const isConvNotFound = error instanceof NotFoundError // ponytail: only fires when conversation 404s — avoids extra request on normal loads const { data: liveSession, isLoading: isSessionLoading } = useQuery({ @@ -155,6 +294,13 @@ export default function ConversationDetailScreen() { } }, [isConvNotFound, isSessionLive, id, serverId, router]) + // True 404 (no live session either): drop the favorite so Open Session + // cannot keep routing into a dead conversation id. + useEffect(() => { + if (!isConvNotFound || isSessionLoading || isSessionLive || !serverId || !id) return + evictStaleConversationFavorite(serverId, id) + }, [isConvNotFound, isSessionLoading, isSessionLive, serverId, id]) + const [infoVisible, setInfoVisible] = useState(false) const [footerHeight, setFooterHeight] = useState(0) // Flips once FlashList reports it has drawn its items (onLoad). Reset per @@ -239,16 +385,98 @@ export default function ConversationDetailScreen() { } }, [conversation]) - // Resume is owned by /session/new (the countdown start screen): it POSTs - // /api/sessions/resume, shows progress, and replaces itself with the session. - // This screen only navigates there. + const qc = useQueryClient() + const { resume, adoptSession } = useSessionActions(serverId, id) + + // Seed the session cache from the resume snapshot (so /session/:id renders + // without a round-trip) then hand off to the live session, carrying the + // conversation it was resumed from. + const navigateToResumedSession = useCallback( + (result: ResumeResult) => { + if (result.sessionSnapshot) { + qc.setQueryData(['session', serverId, result.sessionId], result.sessionSnapshot) + } + const startParams = new URLSearchParams({ server: serverId }) + if (result.projectId) startParams.set('projectId', result.projectId) + const projectPath = result.projectPath ?? conversation?.projectPath + if (projectPath) startParams.set('projectPath', projectPath) + startParams.set('resumedFromConversationId', result.conversationId) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.replace(`/session/${result.sessionId}?${startParams.toString()}` as any) + }, + [qc, serverId, conversation?.projectPath, router], + ) + + // Second-chance resume after the user acknowledges a possible collision. + // `force: true` always proceeds server-side (contract), so a repeat 409 is + // not expected — any error here is a genuine failure. + const forceResume = useCallback(() => { + resume.mutate( + { force: true }, + { + onSuccess: navigateToResumedSession, + onError: (err) => + Alert.alert(t('resume.failed'), err instanceof Error ? err.message : String(err)), + }, + ) + }, [resume, navigateToResumedSession, t]) + + // Take over: stop the process that already owns this conversation, then adopt + // it as a streamer session. Destructive but SAFE — the server waits for the + // old process to actually exit before spawning, so it cannot leave two agents + // writing one transcript (which is what "open anyway" risks). + const takeOverSession = useCallback(() => { + adoptSession.mutate(undefined, { + onSuccess: (data) => router.replace(`/session/${data.sessionId}?server=${serverId}`), + onError: (err) => + Alert.alert(t('resume.takeOverFailed'), err instanceof Error ? err.message : String(err)), + }) + }, [adoptSession, router, serverId, t]) + + // Resume this conversation into a live session. The server soft-blocks with a + // 409 CONVERSATION_BUSY when the conversation may still be open elsewhere; on + // that we confirm with the user — naming what was detected, honestly (it *may* + // still be open, never "is open") — and only then retry with force. A clean + // resume proceeds straight through. const handleResume = useCallback(() => { - const startParams = new URLSearchParams({ server: serverId, resume: id }) - if (conversation?.title) startParams.set('projectName', conversation.title) - if (conversation?.projectPath) startParams.set('projectPath', conversation.projectPath) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - router.push(`/session/new?${startParams.toString()}` as any) - }, [router, serverId, id, conversation]) + resume.mutate( + {}, + { + onSuccess: navigateToResumedSession, + onError: (err) => { + if (err instanceof ConversationBusyError) { + const entries = err.detectedBy.length > 0 ? err.detectedBy : ['unknown'] + const reasons = Array.from( + new Set( + entries.map((d) => + t(`resume.reason.${d}`, { defaultValue: t('resume.reason.unknown') }), + ), + ), + ).join('; ') + // Taking over needs a process we can actually signal. The server only + // reports likelyOwner 'external' when it matched a real PID (argv/cwd); + // a bare mtime hit ('unknown') has nothing to adopt, so we don't offer it. + const canTakeOver = err.likelyOwner === 'external' + Alert.alert(t('resume.collisionTitle'), t('resume.collisionMessage', { reasons }), [ + { text: t('common:button.cancel'), style: 'cancel' }, + ...(canTakeOver + ? [ + { + text: t('resume.takeOver'), + style: 'destructive' as const, + onPress: takeOverSession, + }, + ] + : []), + { text: t('resume.confirm'), onPress: forceResume }, + ]) + } else { + Alert.alert(t('resume.failed'), err instanceof Error ? err.message : String(err)) + } + }, + }, + ) + }, [resume, navigateToResumedSession, forceResume, takeOverSession, t]) const handleShare = useCallback(async () => { if (!conversation) return @@ -300,6 +528,23 @@ export default function ConversationDetailScreen() { const headerActions = ( + {isLive ? : null} + setSearchBarState((prev) => ({ ...prev, open: !prev.open }))} + hitSlop={8} + accessibilityLabel={t('search.open')} + testID="conversation-search-btn" + style={({ pressed }) => [ + styles.headerButton, + searchOpen && styles.headerButtonActive, + { opacity: pressed ? 0.5 : 1 }, + ]} + > + + { void toggleFavorite() @@ -340,6 +585,25 @@ export default function ConversationDetailScreen() { ) + const searchBar = searchOpen ? ( + + setSearchBarState((prev) => ({ ...prev, draft: text }))} + onSubmitEditing={submitInChatSearch} + placeholder={t('search.placeholder')} + placeholderTextColor={theme.text.secondary} + autoFocus={!searchQuery} + returnKeyType="search" + clearButtonMode="while-editing" + autoCapitalize="none" + autoCorrect={false} + /> + + ) : null + if (error) { // A 404 from /api/conversations/:id doesn't mean "gone" — it may mean the // session is live but hasn't written JSONL yet. Check /api/sessions/:id; @@ -348,6 +612,7 @@ export default function ConversationDetailScreen() { return ( + {searchBar} @@ -355,12 +620,23 @@ export default function ConversationDetailScreen() { ) } return ( - + + {searchBar} {t('error.loadFailed')} {isConvNotFound ? t('error.notFound') : error.message} - {isConvNotFound ? null : ( + {isConvNotFound ? ( + router.back()} + accessibilityRole="button" + accessibilityLabel={t('error.back')} + > + {t('error.back')} + + ) : ( refetch()}> {t('common:button.retry')} @@ -375,6 +651,7 @@ export default function ConversationDetailScreen() { return ( + {searchBar} 0 + // The search view keeps raw conversation.messages — its match indexes are + // keyed to REST message_index; mergedMessages (computed above, freeze-aware) + // backs only the tail history view. + const mergedLastMessageId = mergedMessages[mergedMessages.length - 1]?.id + + const hasMessages = mergedMessages.length > 0 const isLoadingMessages = Boolean(hasNextPage || isFetchingNextPage) // `resumable` is absent on older servers — treat undefined as resumable. The @@ -417,6 +699,7 @@ export default function ConversationDetailScreen() { return ( + {searchBar} {isGated ? ( @@ -462,8 +745,8 @@ export default function ConversationDetailScreen() { /> ) : ( - {notResumable ? t('unavailable.cannotResume') : '▶ Resume Session'} + {notResumable + ? t('unavailable.cannotResume') + : resume.isPending + ? t('resume.resuming') + : '▶ Resume Session'} @@ -524,6 +812,16 @@ function makeStyles(theme: Theme) { return StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg.primary }, headerActions: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + headerButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + }, + headerButtonActive: { + backgroundColor: 'rgba(88,166,255,0.12)', + }, providerDot: { width: 8, height: 8, borderRadius: 4 }, inner: { flex: 1 }, skeletonOverlay: { diff --git a/app/help-feedback.tsx b/app/help-feedback.tsx index 644e8dde..364b5ca7 100644 --- a/app/help-feedback.tsx +++ b/app/help-feedback.tsx @@ -26,6 +26,7 @@ import { Check, X, Image as ImageIcon, + PaperPlaneTilt, } from 'phosphor-react-native' import { useTheme, useIsGlass } from '@/contexts/ThemeContext' import { GlassFill } from '@/components/ui/GlassFill' @@ -41,7 +42,7 @@ import { import { pickAndPrepareScreenshot } from '@/services/feedback-screenshot' import { addSafeBreadcrumb } from '@/services/sentry' import { recordDiagnosticEvent } from '@/services/diagnostic-events' -import type { FeedbackCategory, FeedbackReport, FeedbackAttachment } from '@/types/feedback' +import type { FeedbackCategory, FeedbackReport, FeedbackAttachment, FeedbackTransportKind } from '@/types/feedback' const PRIVACY_URL = 'https://threadbase.sh/privacy' const MIN_DESCRIPTION = 10 @@ -77,6 +78,7 @@ export default function HelpFeedbackScreen() { const [submitError, setSubmitError] = useState(null) const [reportId] = useState(makeReportId) const [copied, setCopied] = useState(false) + const [deliveredVia, setDeliveredVia] = useState(null) const diagnostics = useMemo(() => buildFeedbackDiagnostics(), []) @@ -136,6 +138,7 @@ export default function HelpFeedbackScreen() { if (result.ok) { addSafeBreadcrumb('feedback_submitted') recordDiagnosticEvent('feedback_submitted') + setDeliveredVia(result.via) setView('success') } else { // No automatic transport succeeded — offer the copy + guide fallback. @@ -168,6 +171,13 @@ export default function HelpFeedbackScreen() { // ---- Success state ---- if (view === 'success') { + const deliveryNote = + deliveredVia === 'sentry' + ? t('success.viaSentry') + : deliveredVia === 'email' + ? t('success.viaEmail') + : null + return ( @@ -176,6 +186,12 @@ export default function HelpFeedbackScreen() { {t('success.title')} {t('success.message')} + {deliveryNote ? ( + + + {deliveryNote} + + ) : null} setView('landing')} @@ -601,6 +617,13 @@ function styles(theme: Theme) { }, successTitle: { color: theme.text.primary, fontSize: font.lg, fontWeight: '700', marginTop: spacing.sm }, successMessage: { color: theme.text.secondary, fontSize: font.base, textAlign: 'center', lineHeight: 21 }, + deliveryNote: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.xs, + }, + deliveryNoteText: { color: theme.text.secondary, fontSize: font.xs }, stepRow: { flexDirection: 'row', alignItems: 'center', diff --git a/app/index.tsx b/app/index.tsx index da2fc9af..31063d70 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -45,6 +45,8 @@ import { clientLog } from '@/lib/clientLog' import { conversationHref } from '@/lib/conversationHref' import { LoadingOverlay } from '@/components/ui/LoadingOverlay' import { ServerIndexingBanner } from '@/components/servers/ServerIndexingBanner' +import { CacheAlertBanner } from '@/components/servers/CacheAlertBanner' +import { CacheAlertModal } from '@/components/servers/CacheAlertModal' import { ServerStateMessage } from '@/components/servers/ServerStateMessage' import { brand, font, spacing, type Theme } from '@/constants/theme' import { useTheme, useIsGlass } from '@/contexts/ThemeContext' @@ -75,7 +77,7 @@ export default function ProjectsHub() { const theme = useTheme() const isGlass = useIsGlass() const styles = makeStyles(theme) - const { t } = useTranslation(['sessions', 'shared', 'settings']) + const { t } = useTranslation(['sessions', 'shared', 'settings', 'servers']) const router = useRouter() const sessionsLayout = useSettingsStore((s) => s.sessionsLayout) const mergeChats = useSettingsStore((s) => (s as any).mergeChats ?? false) @@ -127,6 +129,8 @@ export default function ProjectsHub() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeServerIds, fetchStatuses, wsConnectedCount]) + const cacheAlert = useServersStore((s) => s.cacheAlert) + const serverCount = activeServerIds.length const allConnected = healthyCount === serverCount && serverCount > 0 const someConnected = healthyCount > 0 @@ -137,6 +141,19 @@ export default function ProjectsHub() { const [statusModalOpen, setStatusModalOpen] = useState(false) const [pickerVisible, setPickerVisible] = useState(false) const [fabNoServerToast, setFabNoServerToast] = useState(false) + const [manualCacheAlertServerId, setManualCacheAlertServerId] = useState(null) + const [cacheAlertToast, setCacheAlertToast] = useState(null) + + // Auto-open for a pending high-severity alert (derived, not stateful); the + // low-severity banner can also open the modal manually via setCacheAlertModalServerId. + // Both auto-close once the store no longer has an alert for that server + // (e.g. resolved from another surface) since neither branch is sticky state. + const highSeverityCacheAlertServerId = displayedServerIds.find( + (id) => cacheAlert[id]?.severity === 'high', + ) ?? null + const cacheAlertModalServerId = highSeverityCacheAlertServerId + ?? (manualCacheAlertServerId && cacheAlert[manualCacheAlertServerId] ? manualCacheAlertServerId : null) + const setCacheAlertModalServerId = setManualCacheAlertServerId // Sort state (hub mode) const [sortBy, setSortBy] = useState('lastActivity') @@ -355,6 +372,12 @@ export default function ProjectsHub() { {/* Shown while server is scanning/indexing conversations on first boot */} + { + const lowSeverityId = displayedServerIds.find((id) => cacheAlert[id]?.severity === 'low') + if (lowSeverityId) setCacheAlertModalServerId(lowSeverityId) + }} + /> + {t('sessions:fab.noServerHint')} )} + {cacheAlertToast && ( + + {cacheAlertToast} + + )} setPickerVisible(false)} /> + setCacheAlertModalServerId(null)} + onResolved={(backupPath) => { + setCacheAlertModalServerId(null) + const message = backupPath + ? t('cacheAlert.successToast', { ns: 'servers', backupPath }) + : t('cacheAlert.successToastNoBackup', { ns: 'servers' }) + setCacheAlertToast(message) + setTimeout(() => setCacheAlertToast(null), 3000) + }} + /> styles(theme), [theme]) + + const servers = useServersStore((st) => st.servers) + const activeServerIds = useServersStore((st) => st.activeServerIds) + const serverIds = useMemo( + () => activeServerIds.filter((id) => !!servers[id]), + [activeServerIds, servers], + ) + + const notifications = useSettingsStore((st) => st.notifications) + + const [pickedId, setPickedId] = useState(null) + const selectedId = + pickedId && serverIds.includes(pickedId) ? pickedId : (serverIds[0] ?? null) + const selected = selectedId ? servers[selectedId] : undefined + const serverLabel = selected?.label?.trim() || selected?.url || selectedId || '—' + + const { data, error, isLoading, isFetching, refetch } = usePushHealth(selectedId) + const [reregistering, setReregistering] = useState(false) + const [actionMsg, setActionMsg] = useState(null) + + const quietPreview = notifications.quietHoursEnabled + ? isInQuietHours(notifications.quietHoursFrom, notifications.quietHoursTo) + : false + + const quietPreviewLabel = quietPreview + ? t('settings:notificationHealth.quietActive') + : t('settings:notificationHealth.quietInactive') + + const handleRetry = useCallback(() => { + setActionMsg(null) + void refetch() + }, [refetch]) + + const handleReregister = useCallback(async () => { + if (!selectedId) return + setReregistering(true) + setActionMsg(null) + try { + const result = await registerPushToken(selectedId) + if (!result.ok) { + const skipMsg = + result.reason === 'permission_denied' + ? t('settings:notificationHealth.reregisterNeedsPermission') + : t('settings:notificationHealth.reregisterNeedsDevice') + setActionMsg(skipMsg) + return + } + setActionMsg(t('settings:notificationHealth.reregistered')) + await refetch() + } catch { + setActionMsg(t('settings:notificationHealth.reregisterFailed')) + } finally { + setReregistering(false) + } + }, [selectedId, refetch, t]) + + if (serverIds.length === 0) { + return ( + + + {t('settings:notificationHealth.emptyTitle')} + {t('settings:notificationHealth.emptyBody')} + + + ) + } + + return ( + + + + + {t('settings:notificationHealth.heading')} + + {t('settings:notificationHealth.subtitle')} + + {serverIds.length > 1 ? ( + + {serverIds.map((id) => { + const label = servers[id]?.label?.trim() || servers[id]?.url || id + const selectedChip = id === selectedId + return ( + { + setPickedId(id) + setActionMsg(null) + }} + accessibilityRole="button" + accessibilityState={{ selected: selectedChip }} + testID={`notif-health-chip-${id}`} + > + + {label} + + + ) + })} + + ) : null} + + + + {t('settings:notificationHealth.quietPreview')} + {quietPreviewLabel} + {notifications.quietHoursEnabled ? ( + + {t('settings:notificationHealth.quietWindow', { + from: notifications.quietHoursFrom, + to: notifications.quietHoursTo, + })} + + ) : null} + + + + + + {t('settings:notificationHealth.retry')} + + void handleReregister()} + disabled={reregistering} + accessibilityRole="button" + testID="notif-health-reregister" + > + + {reregistering + ? t('settings:notificationHealth.reregistering') + : t('settings:notificationHealth.reregister')} + + + + + {isLoading && !data ? ( + + + {t('settings:notificationHealth.loading')} + + ) : null} + + {error && !data ? ( + + + + {error instanceof Error ? error.message : t('settings:notificationHealth.loadFailed')} + + + ) : null} + + {data ? ( + <> + + + {serverLabel} + + {data.available + ? t('settings:notificationHealth.storeAvailable') + : t('settings:notificationHealth.storeUnavailable')} + + + + {data.tokens.length === 0 ? ( + + + {t('settings:notificationHealth.noTokens')} + + ) : ( + data.tokens.map((token, idx) => ( + + )) + )} + + ) : null} + + {actionMsg ? {actionMsg} : null} + + + ) +} + +function TokenCard({ + token, + theme, + isGlass, +}: { + token: PushTokenHealth + theme: Theme + isGlass: boolean +}) { + const { t } = useTranslation('settings') + const s = useMemo(() => styles(theme), [theme]) + const stateLabel = stateLabelFor(t, token.state) + let deliveryHint = t('notificationHealth.hintHealthy') + if (token.state === 'never-delivered') deliveryHint = t('notificationHealth.hintNeverDelivered') + else if (token.state === 'failing' || token.state === 'dead') { + deliveryHint = t('notificationHealth.hintDelivery') + } else if (token.state === 'revoked') deliveryHint = t('notificationHealth.hintRevoked') + + return ( + + + {stateLabel} + {deliveryHint} + {`${t('notificationHealth.platform')}: ${token.platform}`} + + {`${t('notificationHealth.registeredAt')}: ${formatEpoch(token.registeredAt)}`} + + + {`${t('notificationHealth.lastSuccess')}: ${formatEpoch(token.lastSuccessAt)}`} + + + {`${t('notificationHealth.lastFailure')}: ${formatEpoch(token.lastFailureAt)}`} + + {token.lastFailureCode ? ( + + {`${t('notificationHealth.failureCode')}: ${token.lastFailureCode}`} + + ) : null} + + {`${t('notificationHealth.failureStreak')}: ${token.failureStreak}`} + + + ) +} + +function stateLabelFor( + t: (key: + | 'notificationHealth.state.never-delivered' + | 'notificationHealth.state.healthy' + | 'notificationHealth.state.failing' + | 'notificationHealth.state.dead' + | 'notificationHealth.state.revoked') => string, + state: PushTokenState, +): string { + switch (state) { + case 'never-delivered': + return t('notificationHealth.state.never-delivered') + case 'healthy': + return t('notificationHealth.state.healthy') + case 'failing': + return t('notificationHealth.state.failing') + case 'dead': + return t('notificationHealth.state.dead') + case 'revoked': + return t('notificationHealth.state.revoked') + } +} + +function styles(theme: Theme) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.bg.primary }, + content: { padding: spacing.md, gap: spacing.sm, paddingBottom: spacing.xxl }, + centered: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg }, + centeredInline: { alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.lg }, + emptyTitle: { color: theme.text.primary, fontSize: font.lg, fontWeight: '600', textAlign: 'center' }, + emptyBody: { color: theme.text.secondary, fontSize: font.base, textAlign: 'center', lineHeight: 21 }, + loadingText: { color: theme.text.secondary, fontSize: font.base }, + headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.sm }, + heading: { color: theme.text.primary, fontSize: font.xl, fontWeight: '700' }, + subtitle: { color: theme.text.secondary, fontSize: font.base, lineHeight: 21 }, + chips: { gap: spacing.xs, paddingVertical: spacing.xs }, + chip: { + paddingHorizontal: spacing.md, + minHeight: MIN_TOUCH_TARGET, + borderRadius: radius.full, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bg.card, + justifyContent: 'center', + maxWidth: 220, + }, + chipSelected: { borderColor: theme.text.accent, backgroundColor: theme.bg.secondary }, + chipText: { color: theme.text.secondary, fontSize: font.sm }, + chipTextSelected: { color: theme.text.accent, fontWeight: '600' }, + toolbar: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, + toolbarBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + minHeight: MIN_TOUCH_TARGET, + paddingHorizontal: spacing.sm, + }, + toolbarBtnText: { color: theme.text.accent, fontSize: font.sm, fontWeight: '600' }, + card: { + backgroundColor: theme.bg.card, + borderRadius: radius.md, + borderWidth: 1, + borderColor: theme.border, + overflow: 'hidden', + padding: spacing.md, + gap: spacing.xs, + }, + cardGlass: { backgroundColor: 'transparent' }, + cardTitle: { color: theme.text.primary, fontSize: font.base, fontWeight: '700' }, + cardBody: { color: theme.text.secondary, fontSize: font.sm, lineHeight: 19 }, + meta: { color: theme.text.secondary, fontSize: font.xs, fontFamily: 'monospace' }, + errorText: { color: theme.text.danger, fontSize: font.sm }, + actionMsg: { color: theme.text.secondary, fontSize: font.sm }, + }) +} diff --git a/app/paired-devices.tsx b/app/paired-devices.tsx new file mode 100644 index 00000000..16c5e60d --- /dev/null +++ b/app/paired-devices.tsx @@ -0,0 +1,416 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { + View, + Text, + TouchableOpacity, + ScrollView, + StyleSheet, + ActivityIndicator, + Alert, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslation } from 'react-i18next' +import { Devices, ArrowsClockwise, Warning } from 'phosphor-react-native' +import { useTheme, useIsGlass } from '@/contexts/ThemeContext' +import { GlassFill } from '@/components/ui/GlassFill' +import { type Theme, font, radius, spacing } from '@/constants/theme' +import { MIN_TOUCH_TARGET } from '@/constants/a11y' +import { useServersStore } from '@/stores/servers' +import { useDevices, useRevokeDevice } from '@/hooks/useDevices' +import { formatDeviceEpoch } from '@/services/devices' +import { deviceHasCapability, type DeviceCapability, type DeviceView } from '@/types/devices' + +export default function PairedDevicesScreen() { + const { t } = useTranslation(['settings', 'common']) + const theme = useTheme() + const isGlass = useIsGlass() + const s = useMemo(() => styles(theme), [theme]) + + const servers = useServersStore((st) => st.servers) + const activeServerIds = useServersStore((st) => st.activeServerIds) + const serverIds = useMemo( + () => activeServerIds.filter((id) => !!servers[id]), + [activeServerIds, servers], + ) + + const [pickedId, setPickedId] = useState(null) + const selectedId = + pickedId && serverIds.includes(pickedId) ? pickedId : (serverIds[0] ?? null) + const selected = selectedId ? servers[selectedId] : undefined + const thisDeviceId = selected?.deviceId + + const { data, error, isLoading, isFetching, refetch } = useDevices(selectedId) + const revoke = useRevokeDevice(selectedId) + const [actionMsg, setActionMsg] = useState(null) + + const handleRetry = useCallback(() => { + setActionMsg(null) + void refetch() + }, [refetch]) + + const confirmRevoke = useCallback( + (device: DeviceView) => { + if (!selectedId) return + const isThis = thisDeviceId != null && device.deviceId === thisDeviceId + const title = t('pairedDevices.revokeTitle') + const message = isThis + ? t('pairedDevices.revokeThisBody') + : t('pairedDevices.revokeBody', { + name: device.name?.trim() || device.deviceId.slice(0, 8), + }) + + Alert.alert(title, message, [ + { text: t('common:button.cancel'), style: 'cancel' }, + { + text: t('pairedDevices.revokeConfirm'), + style: 'destructive', + onPress: () => { + revoke.mutate(device.deviceId, { + onSuccess: (res) => { + setActionMsg( + res.alreadyRevoked + ? t('pairedDevices.alreadyRevoked') + : t('pairedDevices.revoked'), + ) + }, + onError: () => { + setActionMsg(t('pairedDevices.revokeFailed')) + }, + }) + }, + }, + ]) + }, + [revoke, selectedId, t, thisDeviceId], + ) + + if (serverIds.length === 0) { + return ( + + + {t('pairedDevices.emptyTitle')} + {t('pairedDevices.emptyBody')} + + + ) + } + + const devices = data?.devices ?? [] + const activeDevices = devices.filter((d) => d.revokedAt == null) + const revokedDevices = devices.filter((d) => d.revokedAt != null) + + return ( + + + + + {t('pairedDevices.heading')} + + {t('pairedDevices.subtitle')} + + {serverIds.length > 1 ? ( + + {serverIds.map((id) => { + const label = servers[id]?.label?.trim() || servers[id]?.url || id + const selectedChip = id === selectedId + return ( + { + setPickedId(id) + setActionMsg(null) + }} + accessibilityRole="button" + accessibilityState={{ selected: selectedChip }} + testID={`paired-devices-chip-${id}`} + > + + {label} + + + ) + })} + + ) : null} + + + + {t('pairedDevices.controlNote')} + + + + + + {t('pairedDevices.retry')} + + + + {actionMsg ? ( + + {actionMsg} + + ) : null} + + {isLoading && !data ? ( + + + {t('pairedDevices.loading')} + + ) : null} + + {error && !data ? ( + + + + {error instanceof Error ? error.message : t('pairedDevices.loadFailed')} + + + ) : null} + + {data && !data.available ? ( + + + + + {t('pairedDevices.storeUnavailable')} + + + ) : null} + + {data?.available && activeDevices.length === 0 && revokedDevices.length === 0 ? ( + + + {t('pairedDevices.noDevices')} + + ) : null} + + {activeDevices.map((device) => ( + confirmRevoke(device)} + revoking={revoke.isPending} + /> + ))} + + {revokedDevices.length > 0 ? ( + <> + {t('pairedDevices.revokedSection')} + {revokedDevices.map((device) => ( + + ))} + + ) : null} + + + ) +} + +function DeviceCard({ + device, + isThis, + isGlass, + s, + onRevoke, + revoking, + revoked, +}: { + device: DeviceView + isThis: boolean + isGlass: boolean + s: ReturnType + onRevoke?: () => void + revoking?: boolean + revoked?: boolean +}) { + const { t } = useTranslation('settings') + const canControl = deviceHasCapability(device.capabilities, 'session:control') + const name = + device.name?.trim() || t('pairedDevices.unnamed', { id: device.deviceId.slice(0, 8) }) + + return ( + + + + + {name} + + {isThis ? ( + + {t('pairedDevices.thisDevice')} + + ) : null} + + + {canControl && !revoked ? ( + {t('pairedDevices.hasControl')} + ) : null} + + + {device.capabilities.map((cap) => ( + + {capabilityLabel(cap, t)} + + ))} + {device.capabilities.length === 0 ? ( + {t('pairedDevices.noCapabilities')} + ) : null} + + + + {t('pairedDevices.createdAt', { at: formatDeviceEpoch(device.createdAt) })} + + + {t('pairedDevices.lastSeen', { at: formatDeviceEpoch(device.lastSeenAt) })} + + {revoked && device.revokedAt != null ? ( + + {t('pairedDevices.revokedAt', { at: formatDeviceEpoch(device.revokedAt) })} + + ) : null} + + {!revoked && onRevoke ? ( + + {t('pairedDevices.revoke')} + + ) : null} + + ) +} + +function capabilityLabel( + cap: DeviceCapability, + t: ReturnType>['t'], +): string { + switch (cap) { + case 'history:read': + return t('pairedDevices.capability.historyRead') + case 'session:control': + return t('pairedDevices.capability.sessionControl') + case 'fs:browse': + return t('pairedDevices.capability.fsBrowse') + case 'fs:upload': + return t('pairedDevices.capability.fsUpload') + case 'notifications': + return t('pairedDevices.capability.notifications') + case 'admin': + return t('pairedDevices.capability.admin') + } +} + +function styles(theme: Theme) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.bg.primary }, + content: { padding: spacing.lg, gap: spacing.md, paddingBottom: spacing.xl * 2 }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: spacing.lg, gap: spacing.sm }, + centeredInline: { alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.lg }, + emptyTitle: { color: theme.text.primary, fontSize: font.lg, fontWeight: '600', textAlign: 'center' }, + emptyBody: { color: theme.text.secondary, fontSize: font.sm, textAlign: 'center', lineHeight: 20 }, + headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + heading: { color: theme.text.primary, fontSize: font.xl, fontWeight: '700' }, + subtitle: { color: theme.text.secondary, fontSize: font.sm, lineHeight: 20 }, + chips: { gap: spacing.sm, paddingVertical: spacing.xs }, + chip: { + minHeight: MIN_TOUCH_TARGET, + paddingHorizontal: spacing.md, + borderRadius: radius.full, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bg.card, + justifyContent: 'center', + maxWidth: 220, + }, + chipSelected: { borderColor: theme.text.accent, backgroundColor: theme.bg.secondary }, + chipText: { color: theme.text.secondary, fontSize: font.sm }, + chipTextSelected: { color: theme.text.accent, fontWeight: '600' }, + toolbar: { flexDirection: 'row', gap: spacing.sm }, + toolbarBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + minHeight: MIN_TOUCH_TARGET, + paddingHorizontal: spacing.md, + }, + toolbarBtnText: { color: theme.text.accent, fontSize: font.sm, fontWeight: '600' }, + actionMsg: { color: theme.text.secondary, fontSize: font.sm }, + loadingText: { color: theme.text.secondary, fontSize: font.sm }, + card: { + backgroundColor: theme.bg.card, + borderRadius: radius.md, + borderWidth: 1, + borderColor: theme.border, + padding: spacing.md, + gap: spacing.sm, + overflow: 'hidden', + }, + cardGlass: { backgroundColor: 'transparent' }, + cardMuted: { opacity: 0.65 }, + cardBody: { color: theme.text.secondary, fontSize: font.sm, lineHeight: 20 }, + errorText: { color: theme.text.danger, fontSize: font.sm, lineHeight: 20 }, + warnRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm }, + warnText: { flex: 1, color: theme.text.warning, fontSize: font.sm, lineHeight: 20 }, + sectionLabel: { + color: theme.text.secondary, + fontSize: font.xs, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, + marginTop: spacing.sm, + }, + deviceHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' }, + deviceName: { color: theme.text.primary, fontSize: font.base, fontWeight: '600', flexShrink: 1 }, + thisBadge: { + backgroundColor: theme.bg.secondary, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderWidth: 1, + borderColor: theme.text.accent, + }, + thisBadgeText: { color: theme.text.accent, fontSize: font.xs, fontWeight: '700' }, + controlWarn: { color: theme.text.warning, fontSize: font.xs, lineHeight: 16 }, + capRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, + capChip: { + borderRadius: radius.sm, + borderWidth: 1, + borderColor: theme.border, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + }, + capChipText: { color: theme.text.secondary, fontSize: font.xs }, + meta: { color: theme.text.secondary, fontSize: font.xs }, + revokeBtn: { + alignSelf: 'flex-start', + minHeight: MIN_TOUCH_TARGET, + justifyContent: 'center', + paddingHorizontal: spacing.sm, + }, + revokeBtnText: { color: theme.text.danger, fontSize: font.sm, fontWeight: '600' }, + }) +} diff --git a/app/server-health.tsx b/app/server-health.tsx new file mode 100644 index 00000000..2f6c40de --- /dev/null +++ b/app/server-health.tsx @@ -0,0 +1,474 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { + View, + Text, + TouchableOpacity, + ScrollView, + StyleSheet, + ActivityIndicator, + Share, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import * as Clipboard from 'expo-clipboard' +import { useTranslation } from 'react-i18next' +import { + Heartbeat, + ClipboardText, + Check, + Export, + ArrowsClockwise, + WarningCircle, + CheckCircle, + XCircle, + Question, +} from 'phosphor-react-native' +import { useTheme, useIsGlass } from '@/contexts/ThemeContext' +import { GlassFill } from '@/components/ui/GlassFill' +import { type Theme, font, radius, spacing } from '@/constants/theme' +import { useServersStore } from '@/stores/servers' +import { useServerDiagnostics } from '@/hooks/useServerDiagnostics' +import { + isSupportedDiagnosticsContract, + needsRemediation, + serverDiagnosticsToText, +} from '@/services/server-diagnostics' +import type { + CheckStatus, + ServerDiagnosticCheck, +} from '@/types/server-diagnostics' +import { MIN_TOUCH_TARGET } from '@/constants/a11y' + +export default function ServerHealthScreen() { + const { t } = useTranslation(['servers', 'common']) + const theme = useTheme() + const isGlass = useIsGlass() + const s = useMemo(() => styles(theme), [theme]) + + const servers = useServersStore((st) => st.servers) + const activeServerIds = useServersStore((st) => st.activeServerIds) + const serverIds = useMemo( + () => activeServerIds.filter((id) => !!servers[id]), + [activeServerIds, servers], + ) + + const [pickedId, setPickedId] = useState(null) + const selectedId = + pickedId && serverIds.includes(pickedId) ? pickedId : (serverIds[0] ?? null) + + const selected = selectedId ? servers[selectedId] : undefined + const serverLabel = selected?.label?.trim() || selected?.url || selectedId || '—' + + const { data, error, isFetching, isLoading, refetch } = useServerDiagnostics(selectedId) + + const [copied, setCopied] = useState(false) + const [actionError, setActionError] = useState(null) + + const handleCopy = useCallback(async () => { + if (!data || !selectedId) return + try { + await Clipboard.setStringAsync(serverDiagnosticsToText(serverLabel, data)) + setCopied(true) + setActionError(null) + } catch { + setActionError(t('servers:health.copyFailed')) + } + }, [data, selectedId, serverLabel, t]) + + const handleShare = useCallback(async () => { + if (!data || !selectedId) return + try { + await Share.share({ message: serverDiagnosticsToText(serverLabel, data) }) + setActionError(null) + } catch { + setActionError(t('servers:health.shareFailed')) + } + }, [data, selectedId, serverLabel, t]) + + const handleRetry = useCallback(() => { + setCopied(false) + setActionError(null) + void refetch() + }, [refetch]) + + const overallStatusLabel = + data?.overall === 'ok' + ? t('servers:health.status.ok') + : data?.overall === 'degraded' + ? t('servers:health.status.degraded') + : data?.overall === 'failed' + ? t('servers:health.status.failed') + : t('servers:health.status.unknown') + + if (serverIds.length === 0) { + return ( + + + {t('servers:health.emptyTitle')} + {t('servers:health.emptyBody')} + + + ) + } + + return ( + + + + + {t('servers:health.heading')} + + {t('servers:health.subtitle')} + + {serverIds.length > 1 ? ( + + {serverIds.map((id) => { + const label = servers[id]?.label?.trim() || servers[id]?.url || id + const selectedChip = id === selectedId + return ( + { + setPickedId(id) + setCopied(false) + setActionError(null) + }} + accessibilityRole="button" + accessibilityState={{ selected: selectedChip }} + testID={`server-health-chip-${id}`} + > + + {label} + + + ) + })} + + ) : null} + + + + + {t('servers:health.retry')} + + + + {isLoading && !data ? ( + + + {t('servers:health.loading')} + + ) : null} + + {error && !data ? ( + + + + {error instanceof Error ? error.message : t('servers:health.loadFailed')} + + + ) : null} + + {data ? ( + <> + {!isSupportedDiagnosticsContract(data) ? ( + + + + {t('servers:health.unsupportedContract', { version: data.contractVersion })} + + + ) : null} + + + + + + + {t('servers:health.overall')} + {overallStatusLabel} + + + + {t('servers:health.generatedAt', { at: data.generatedAt })} + + + + {data.checks.map((check) => ( + + ))} + + {actionError ? {actionError} : null} + + + {copied ? ( + + ) : ( + + )} + + {copied ? t('servers:health.copied') : t('servers:health.copy')} + + + + + + {t('servers:health.share')} + + + ) : null} + + + ) +} + +function StatusIcon({ status, theme }: { status: CheckStatus; theme: Theme }) { + const size = 22 + switch (status) { + case 'ok': + return + case 'degraded': + return + case 'failed': + return + default: + return + } +} + +function CheckCard({ + check, + theme, + isGlass, +}: { + check: ServerDiagnosticCheck + theme: Theme + isGlass: boolean +}) { + const { t } = useTranslation('servers') + const s = useMemo(() => styles(theme), [theme]) + const showRemediation = needsRemediation(check.remediation) + + const checkTitle = (() => { + switch (check.id) { + case 'streamer': + return t('health.checks.streamer') + case 'provider:claude-code': + return t('health.checks.providerClaude') + case 'provider:codex-cli': + return t('health.checks.providerCodex') + case 'cache': + return t('health.checks.cache') + case 'pty': + return t('health.checks.pty') + case 'filesystem': + return t('health.checks.filesystem') + default: + return check.id + } + })() + + const remediationTitle = (() => { + switch (check.remediation) { + case 'PROVIDER_NOT_INSTALLED': + return t('health.remediation.PROVIDER_NOT_INSTALLED.title') + case 'PROVIDER_VERSION_UNVERIFIED': + return t('health.remediation.PROVIDER_VERSION_UNVERIFIED.title') + case 'DB_UNAVAILABLE': + return t('health.remediation.DB_UNAVAILABLE.title') + case 'DB_MIGRATION_PENDING': + return t('health.remediation.DB_MIGRATION_PENDING.title') + case 'PTY_UNAVAILABLE': + return t('health.remediation.PTY_UNAVAILABLE.title') + case 'CACHE_DEGRADED': + return t('health.remediation.CACHE_DEGRADED.title') + case 'CLOCK_SKEWED': + return t('health.remediation.CLOCK_SKEWED.title') + case 'FS_SCOPE_MISSING': + return t('health.remediation.FS_SCOPE_MISSING.title') + case 'NONE': + return t('health.remediation.NONE.title') + } + })() + + const remediationAction = (() => { + switch (check.remediation) { + case 'PROVIDER_NOT_INSTALLED': + return t('health.remediation.PROVIDER_NOT_INSTALLED.action') + case 'PROVIDER_VERSION_UNVERIFIED': + return t('health.remediation.PROVIDER_VERSION_UNVERIFIED.action') + case 'DB_UNAVAILABLE': + return t('health.remediation.DB_UNAVAILABLE.action') + case 'DB_MIGRATION_PENDING': + return t('health.remediation.DB_MIGRATION_PENDING.action') + case 'PTY_UNAVAILABLE': + return t('health.remediation.PTY_UNAVAILABLE.action') + case 'CACHE_DEGRADED': + return t('health.remediation.CACHE_DEGRADED.action') + case 'CLOCK_SKEWED': + return t('health.remediation.CLOCK_SKEWED.action') + case 'FS_SCOPE_MISSING': + return t('health.remediation.FS_SCOPE_MISSING.action') + case 'NONE': + return t('health.remediation.NONE.action') + } + })() + + const statusLabel = (() => { + switch (check.status) { + case 'ok': + return t('health.status.ok') + case 'degraded': + return t('health.status.degraded') + case 'failed': + return t('health.status.failed') + case 'unknown': + return t('health.status.unknown') + } + })() + + return ( + + + + + + {checkTitle} + {statusLabel} + + + {check.summary} + {check.detail + ? Object.entries(check.detail).map(([k, v]) => ( + + {`${k}: ${v === null ? '—' : String(v)}`} + + )) + : null} + {showRemediation ? ( + + {check.remediation} + {remediationTitle} + {remediationAction} + + ) : null} + + ) +} + +function styles(theme: Theme) { + const warn = theme.text.warning + return StyleSheet.create({ + container: { flex: 1, backgroundColor: theme.bg.primary }, + content: { padding: spacing.md, gap: spacing.sm, paddingBottom: spacing.xxl }, + centered: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg }, + centeredInline: { alignItems: 'center', justifyContent: 'center', gap: spacing.sm, paddingVertical: spacing.lg }, + emptyTitle: { color: theme.text.primary, fontSize: font.lg, fontWeight: '600', textAlign: 'center' }, + emptyBody: { color: theme.text.secondary, fontSize: font.base, textAlign: 'center', lineHeight: 21 }, + loadingText: { color: theme.text.secondary, fontSize: font.base }, + headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.sm }, + heading: { color: theme.text.primary, fontSize: font.xl, fontWeight: '700' }, + subtitle: { color: theme.text.secondary, fontSize: font.base, lineHeight: 21, marginBottom: spacing.xs }, + serverChips: { gap: spacing.xs, paddingVertical: spacing.xs }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + minHeight: MIN_TOUCH_TARGET, + borderRadius: radius.full, + borderWidth: 1, + borderColor: theme.border, + backgroundColor: theme.bg.card, + justifyContent: 'center', + maxWidth: 220, + }, + chipSelected: { borderColor: theme.text.accent, backgroundColor: theme.bg.secondary }, + chipText: { color: theme.text.secondary, fontSize: font.sm }, + chipTextSelected: { color: theme.text.accent, fontWeight: '600' }, + toolbar: { flexDirection: 'row', justifyContent: 'flex-end' }, + retryBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + minHeight: MIN_TOUCH_TARGET, + paddingHorizontal: spacing.sm, + }, + retryText: { color: theme.text.accent, fontSize: font.sm, fontWeight: '600' }, + card: { + backgroundColor: theme.bg.card, + borderRadius: radius.md, + borderWidth: 1, + borderColor: theme.border, + overflow: 'hidden', + padding: spacing.md, + gap: spacing.sm, + }, + cardGlass: { backgroundColor: 'transparent' }, + overallRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + overallBody: { flex: 1, gap: 2 }, + overallLabel: { color: theme.text.secondary, fontSize: font.xs }, + overallValue: { color: theme.text.primary, fontSize: font.lg, fontWeight: '700' }, + metaLine: { color: theme.text.secondary, fontSize: font.xs }, + checkHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + checkHeaderBody: { flex: 1 }, + checkId: { color: theme.text.primary, fontSize: font.base, fontWeight: '600' }, + checkStatus: { color: theme.text.secondary, fontSize: font.xs }, + checkSummary: { color: theme.text.secondary, fontSize: font.sm, lineHeight: 20 }, + detailLine: { color: theme.text.secondary, fontSize: font.xs, fontFamily: 'monospace' }, + remediationBox: { + marginTop: spacing.xs, + padding: spacing.sm, + borderRadius: radius.sm, + backgroundColor: theme.bg.secondary, + gap: 4, + }, + remediationCode: { color: theme.text.secondary, fontSize: font.xs, fontFamily: 'monospace' }, + remediationTitle: { color: theme.text.primary, fontSize: font.sm, fontWeight: '600' }, + remediationAction: { color: theme.text.secondary, fontSize: font.sm, lineHeight: 19 }, + errorText: { color: theme.text.danger, fontSize: font.sm }, + warnText: { color: warn, fontSize: font.sm, lineHeight: 19 }, + primaryBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + backgroundColor: theme.text.accent, + borderRadius: radius.md, + paddingVertical: spacing.md, + minHeight: MIN_TOUCH_TARGET, + marginTop: spacing.sm, + }, + primaryBtnText: { color: theme.text.onAccent, fontSize: font.base, fontWeight: '600' }, + secondaryBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + backgroundColor: theme.bg.card, + borderRadius: radius.md, + borderWidth: 1, + borderColor: theme.border, + paddingVertical: spacing.md, + minHeight: MIN_TOUCH_TARGET, + }, + secondaryBtnText: { color: theme.text.accent, fontSize: font.base, fontWeight: '600' }, + }) +} diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 8dd2303f..5b54e861 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -12,9 +12,10 @@ import { import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { SafeAreaView } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { InfoIcon, PencilSimple, Star, StopCircle } from 'phosphor-react-native' +import { useLocalSearchParams, useRouter, useNavigation } from 'expo-router' +import { InfoIcon, PencilSimple, Star, StopCircle, GitDiff } from 'phosphor-react-native' import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge' +import { deriveSessionPresentation } from '@/lib/sessionPresentation' import { useSessionDetail } from '@/hooks/useSession' import { useSessionActions } from '@/hooks/useSessionActions' import { useTerminalStream } from '@/hooks/useTerminalStream' @@ -39,6 +40,16 @@ import { LiveConversationView } from '@/components/conversation/LiveConversation import { TerminalView } from '@/components/terminal/TerminalView' import { ProgressBar } from '@/components/ui/ProgressBar' import { clientLog } from '@/lib/clientLog' +import { clearSessionUsed, wasSessionUsed } from '@/lib/sessionUsage' +import { + evictStaleSessionFavorite, + rehydrateSessionAfterReconnect, + removeSessionFromEagerCache, +} from '@/lib/sessionLifecycle' +import { NotFoundError } from '@/services/api-client' +import { preferRawTerminal } from '@/lib/renderConfidence' +import { ReviewSheet } from '@/components/review/ReviewSheet' +import { useConversation } from '@/hooks/useConversations' const PENDING_PHRASES = [ "Claude is putting on its thinking cap…", @@ -88,6 +99,10 @@ function PendingSessionScreen({ const [elapsedMs, setElapsedMs] = useState(0) const [stuck, setStuck] = useState(false) const [waitNonce, setWaitNonce] = useState(0) + // Synchronously invalidate in-flight ticks from the previous wait window. + // Fake-timer tests (and rare real races) can flush an old interval after + // "Wait more" sets stuck=false but before the effect cleanup runs. + const waitEpochRef = useRef(0) // pendingId is `pending_` (see navigateToNewSession) — strip // the prefix to get the id the server/stop-session API actually knows. const realSessionId = pendingId.replace(/^pending_/, '') @@ -103,10 +118,12 @@ function PendingSessionScreen({ // Bumping waitNonce (via "Wait more") re-arms this timer from a fresh // baseline. The press handler resets elapsed/stuck (not this effect) so the // reset stays out of the effect body — react-hooks/set-state-in-effect — and - // the new baseline lands before the next 250ms tick can re-flip `stuck`. + // waitEpochRef drops any stale tick that would re-flip `stuck`. useEffect(() => { + const epoch = waitEpochRef.current const startedAt = Date.now() const timer = setInterval(() => { + if (waitEpochRef.current !== epoch) return const elapsed = Date.now() - startedAt setElapsedMs(elapsed) if (elapsed >= STUCK_AFTER_MS) setStuck(true) @@ -115,6 +132,7 @@ function PendingSessionScreen({ }, [waitNonce]) const handleWaitMore = () => { + waitEpochRef.current += 1 setElapsedMs(0) setStuck(false) setWaitNonce((n) => n + 1) @@ -388,7 +406,7 @@ function formatElapsed(ms: number): string { } export default function SessionDetailScreen() { - const { t } = useTranslation(['terminal', 'common']) + const { t } = useTranslation(['terminal', 'common', 'sessions', 'conversation']) const theme = useTheme() const styles = makeStyles(theme) const { id, server, starting } = useLocalSearchParams<{ @@ -397,16 +415,19 @@ export default function SessionDetailScreen() { starting?: string }>() const router = useRouter() + const navigation = useNavigation() // Fall back to first server if no server param provided (backwards compat) const fallbackServerId = useServersStore((s) => s.activeServerIds[0] ?? '') const serverId = server || fallbackServerId - const { sessionView } = useSettingsStore() + const { sessionView, setSessionView } = useSettingsStore() + const [forceRawTerminal, setForceRawTerminal] = useState(false) const isStarting = starting === '1' const isPending = (id?.startsWith('pending_') ?? false) || isStarting - const { data: session, isLoading } = useSessionDetail(serverId, id) + const { data: session, isLoading, error: sessionError } = useSessionDetail(serverId, id) const isDetailSlow = useLoadingStateStore((s) => s.slowCounts['session-detail'] > 0) + const isSessionNotFound = sessionError instanceof NotFoundError // When the app returns from background, iOS may have torn down the WS // connection without firing onclose, and the streamer may have restarted @@ -419,15 +440,25 @@ export default function SessionDetailScreen() { const sub = AppState.addEventListener('change', (nextState) => { if (nextState === 'active') { wsManager.forceReconnect(serverId) - qc.invalidateQueries({ queryKey: ['session', serverId, id] }) + const cached = qc.getQueryData<{ + boundConversationId?: string | null + conversationId?: string | null + }>(['session', serverId, id]) + rehydrateSessionAfterReconnect( + qc, + serverId, + id, + cached?.boundConversationId ?? cached?.conversationId, + ) return } - // Backgrounding/inactive: proactively ask the server to hold this session - // now instead of waiting out its ~4.5-min grace timer. The PTY is put on - // hold (SIGINT + screen disposal, history intact) and resumes on the next + // Backgrounding/inactive: proactively (re)arm the server's ~4.5-min grace + // timer for this session, same as a WS disconnect would. The session keeps + // running until the timer elapses; only then is the PTY put on hold + // (SIGINT + screen disposal, history intact), resuming on the next // subscribe (the 'active' branch above force-reconnects). Harmless no-op // server-side if the session isn't live. iOS suspends JS right after this, - // so it's best-effort — the grace timer remains the backstop. + // so it's best-effort — the WS-disconnect grace timer remains the backstop. if (nextState === 'background') { wsManager.send(serverId, { type: 'hold_session', sessionId: id }) } @@ -435,6 +466,14 @@ export default function SessionDetailScreen() { return () => sub.remove() }, [serverId, id, isPending, qc]) + // Stale favorite / hub row → vanished session: evict caches so back-nav + // cannot reopen the same dead id. + useEffect(() => { + if (!isSessionNotFound || !serverId || !id) return + removeSessionFromEagerCache(qc, serverId, id) + evictStaleSessionFavorite(serverId, id) + }, [isSessionNotFound, serverId, id, qc]) + // Connection staleness: a dead WS must not masquerade as a live session. // Delay the visible stale state so brief WS reconnects don't interrupt the UI. const wsStatus = useWsStatus(serverId) @@ -451,6 +490,7 @@ export default function SessionDetailScreen() { const [infoVisible, setInfoVisible] = useState(false) const [renameSheetVisible, setRenameSheetVisible] = useState(false) + const [reviewVisible, setReviewVisible] = useState(false) const [pendingPlan, setPendingPlan] = useState(null) const [planVisible, setPlanVisible] = useState(false) @@ -460,9 +500,50 @@ export default function SessionDetailScreen() { const getName = useSessionNamesStore((s) => s.getName) const renameSession = useRenameSession(serverId) - const sessionName = getName(serverId, id) ?? session?.projectName + // User rename wins; then the JSONL-derived conversation name; then project name. + const sessionName = getName(serverId, id) ?? session?.sessionName ?? session?.projectName + + const { sendKeys, sendInput, stopSession } = useSessionActions(serverId, id ?? '') + const reviewConversationId = session?.boundConversationId ?? session?.conversationId ?? '' + const { data: reviewConversation } = useConversation(serverId, reviewConversationId, { + enabled: Boolean(serverId && reviewConversationId), + }) + const reviewMessages = reviewConversation?.messages ?? [] - const { sendKeys } = useSessionActions(serverId, id ?? '') + // Bug 16: leaving a never-used fresh session should hard-stop the PTY so it + // doesn't linger in the hub as an empty idle entry. Fire-and-forget — don't + // block the back gesture. Skips resumes and sessions the user already typed in. + useEffect(() => { + if (isPending || !id) return + const unsub = navigation.addListener('beforeRemove', () => { + const unusedFresh = + session != null && + session.ptyAttached === true && + (session.status === 'running' || session.status === 'waiting_input') && + session.promptCount === 0 && + !session.resumedFromConversationId && + !wasSessionUsed(id) + if (!unusedFresh) return + clientLog.info('session', 'discard unused empty session on back', { sessionId: id, serverId }) + clearSessionUsed(id) + stopSession.mutate(undefined, { + onError: (err) => { + clientLog.info('session', 'discard stop failed', { + sessionId: id, + err: err instanceof Error ? err.message : String(err), + }) + }, + }) + }) + return unsub + }, [ + navigation, + isPending, + id, + serverId, + session, + stopSession.mutate, + ]) // Mirrors the `isLive` check computed later (post early-returns) — needed // here too since useTerminalStream must be called unconditionally, before @@ -471,7 +552,12 @@ export default function SessionDetailScreen() { session?.ptyAttached === true && (session?.status === 'waiting_input' || session?.status === 'running') && !(session != null && isTerminalSession(session)) - const { isStreaming } = useTerminalStream(serverId, id ?? '', !isLiveForStream) + const { isStreaming, parseConfidence, lines: streamPreviewLines } = useTerminalStream( + serverId, + id ?? '', + !isLiveForStream, + session?.provider, + ) // Esc interrupts the agent's current response without killing the PTY session. const stopResponse = () => { sendKeys.mutate('\x1b', { @@ -565,7 +651,16 @@ export default function SessionDetailScreen() { console.log(`[waking-backstop] still waking after ${WAKING_UP_BACKSTOP_MS}ms — invalidating session ${id} + WS reconnect`) } wsManager.forceReconnect(serverId) - void qc.invalidateQueries({ queryKey: ['session', serverId, id] }) + const cached = qc.getQueryData<{ + boundConversationId?: string | null + conversationId?: string | null + }>(['session', serverId, id]) + rehydrateSessionAfterReconnect( + qc, + serverId, + id, + cached?.boundConversationId ?? cached?.conversationId, + ) }, WAKING_UP_BACKSTOP_MS) return () => clearTimeout(timer) }, [isWakingUpEarly, serverId, id, qc]) @@ -604,9 +699,12 @@ export default function SessionDetailScreen() { if ( session && + session.ownership !== 'external' && session.ptyAttached === false && (session.status === 'running' || session.status === 'waiting_input') ) { + // External sessions are read-only — never surface the Overtake path (which + // SIGTERMs the user's real terminal process). Gated regardless of routing. return } @@ -632,6 +730,9 @@ export default function SessionDetailScreen() { { label: 'Branch', value: session?.branch }, { label: 'Machine', value: session?.machineName }, { label: 'Status', value: session?.status ?? (isLoading ? 'loading…' : 'not found') }, + { label: 'Model', value: session?.model }, + { label: 'Effort', value: session?.effort }, + { label: 'Permission Mode', value: session?.permissionMode }, { label: 'PTY Attached', value: session != null ? String(session.ptyAttached) : undefined }, { label: 'Prompt Count', value: session != null ? String(session.promptCount) : undefined }, { label: 'Elapsed', value: session != null ? formatElapsed(session.elapsedMs) : undefined }, @@ -659,13 +760,22 @@ export default function SessionDetailScreen() { return } return ( - + {t('session.notFound')} - {`No session found for ID:\n${id}`} + {t('session.notFoundBody')} + router.back()} + accessibilityRole="button" + accessibilityLabel={t('session.backToHub')} + > + {t('session.backToHub')} + {infoModal} @@ -712,6 +822,15 @@ export default function SessionDetailScreen() { > + setReviewVisible(true)} + hitSlop={8} + accessibilityLabel={t('conversation:review.open')} + style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} + > + + setInfoVisible(true)} @@ -742,6 +861,12 @@ export default function SessionDetailScreen() { const isLive = session.ptyAttached === true && (session.status === 'waiting_input' || session.status === 'running') + const presentation = deriveSessionPresentation(session) + const capabilityLabel = presentation.capabilities.isObserveOnly + ? t('sessions:capability.observeOnly') + : presentation.live + ? t('sessions:capability.liveControl') + : null // A live PTY may not have a conversationId yet (no JSONL written), but it can // already be showing an interactive prompt. LiveConversationView needs the @@ -753,6 +878,30 @@ export default function SessionDetailScreen() { // for REST so history resolves; fall back to conversationId for Claude / pre-bind. const historyConversationId = session.boundConversationId ?? session.conversationId const hasConversationId = !!historyConversationId + const renderMode = preferRawTerminal({ + sessionView: forceRawTerminal ? 'terminal' : sessionView, + hasConversationId, + // Message count is owned by LiveConversationView; session-level routing + // only gates on preference, conversation id, and parse confidence. + conversationMessageCount: forceRawTerminal ? 0 : 1, + ptyVisibleLineCount: streamPreviewLines.length, + parseConfidence, + }) + const showTerminalSurface = + sessionView === 'terminal' || + !hasConversationId || + forceRawTerminal || + parseConfidence === 'low' || + renderMode.mode === 'terminal' + const viewModeLabel = showTerminalSurface + ? t('session.viewModeTerminal') + : t('session.viewModeChat') + const rawFallbackBanner = + parseConfidence === 'low' || renderMode.reason === 'low_parse_confidence' + ? t('session.rawFallbackBanner') + : forceRawTerminal + ? t('session.ptyActiveFallbackBanner') + : null const noAttachEmptyPlaceholder = session.ptyAttached === false && @@ -764,7 +913,32 @@ export default function SessionDetailScreen() { {session ? ( - + + {session.provider ? ( + + {session.provider === 'codex-cli' ? 'Codex' : 'Claude'} + + ) : null} + {capabilityLabel ? ( + + {capabilityLabel} + + ) : null} + {isLive ? ( + { + const next = showTerminalSurface ? 'chat' : 'terminal' + if (!hasConversationId && next === 'chat') return + setForceRawTerminal(false) + setSessionView(next) + }} + accessibilityRole="button" + accessibilityLabel={viewModeLabel} + testID="session-view-mode-chip" + > + {viewModeLabel} + + ) : null} {formatElapsed(session.elapsedMs)} {t('session.prompts', { count: session.promptCount })} @@ -777,11 +951,18 @@ export default function SessionDetailScreen() { {showReconnectBanner ? ( ) : null} + {rawFallbackBanner ? ( + + {rawFallbackBanner} + + ) : null} - {sessionView === 'terminal' || !hasConversationId ? ( + {showTerminalSurface ? ( { setPlanVisible(false); setPendingPlan(null) }} @@ -792,9 +973,11 @@ export default function SessionDetailScreen() { serverId={serverId} sessionId={id} conversationId={historyConversationId!} + provider={session.provider} disabled={isWakingUp} pendingPlan={planVisible ? pendingPlan : null} onClosePlan={() => { setPlanVisible(false); setPendingPlan(null) }} + onPreferRawTerminal={() => setForceRawTerminal(true)} /> )} @@ -862,6 +1045,20 @@ export default function SessionDetailScreen() { }} onCancel={() => setRenameSheetVisible(false)} /> + + setReviewVisible(false)} + onSendNote={(note) => { + sendInput.mutate(note, { + onSettled: () => setReviewVisible(false), + }) + }} + /> ) } @@ -882,6 +1079,15 @@ function makeStyles(theme: Theme) { }, elapsed: { color: theme.text.secondary, fontSize: font.sm }, prompts: { color: theme.text.secondary, fontSize: font.sm }, + metaChip: { color: theme.text.secondary, fontSize: font.xs, fontWeight: '600' }, + rawBanner: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: theme.bg.secondary, + borderBottomWidth: 1, + borderBottomColor: theme.border, + }, + rawBannerText: { color: theme.text.warning, fontSize: font.xs, lineHeight: 16 }, body: { flex: 1 }, // Content is frozen while the WS is down — make it read as stale, not live. staleContent: { opacity: 0.45 }, @@ -934,5 +1140,18 @@ function makeStyles(theme: Theme) { textAlign: 'center', lineHeight: 20, }, + retryBtn: { + backgroundColor: theme.text.accent, + borderRadius: radius.md, + paddingHorizontal: spacing.lg, + minHeight: 44, + justifyContent: 'center', + alignItems: 'center', + }, + retryBtnText: { + color: theme.text.onAccent, + fontWeight: '700', + fontSize: font.base, + }, }) } diff --git a/app/session/_layout.tsx b/app/session/_layout.tsx index 7bfab41e..378ccf13 100644 --- a/app/session/_layout.tsx +++ b/app/session/_layout.tsx @@ -5,8 +5,13 @@ import { Stack } from 'expo-router' // `[id]` inside this stack once the start POST resolves; with a single route // left, back() bubbles to the parent stack and lands on the hub. export default function SessionLayout() { + // freezeOnBlur: a pushed-under session screen stays mounted in the native + // stack, so without this its WS handlers keep firing on every frame and its + // VirtualTerminal keeps growing — the app slows down linearly with the number + // of sessions opened in one run. Freezing suspends the hidden screen's render + // and effects until it's focused again. return ( - + diff --git a/app/settings.tsx b/app/settings.tsx index c03ee6c6..b9300c7f 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -418,7 +418,11 @@ await refreshServerInfo(serverId) const handleScanQrSuccess = async (result: ExchangeResult) => { setQrScannerOpen(false) const label = result.machineName?.trim() || undefined - const addResult = await addServer(result.url, result.apiKey, label) + const addResult = await addServer(result.url, result.apiKey, label, { + deviceId: result.deviceId ?? undefined, + deviceToken: result.deviceToken ?? undefined, + capabilities: result.capabilities ?? undefined, + }) if (typeof addResult === 'string') { wsManager.connect(addResult, result.url, result.apiKey) } @@ -621,7 +625,7 @@ await refreshServerInfo(serverId) ) : null} - + setNotifications({ waitingInput: v })} /> @@ -630,10 +634,17 @@ await refreshServerInfo(serverId) setNotifications({ diffReady: v })} /> setNotifications({ showBadge: v })} /> setNotifications({ quietHoursEnabled: v })} /> - + {t('notifications.sendTest')} - + router.push('/notification-health')} + testID="settings-notification-health-row" + > + {t('notificationHealth.openRow')} + + @@ -878,7 +889,6 @@ await refreshServerInfo(serverId) description={t('permissions.notificationsDesc')} status={permStatuses.notifications} onPress={permStatuses.notifications === 'undetermined' ? () => requestPermission('notifications') : openPermissionSettings} - badge={t('comingSoonBadge')} isLast /> @@ -899,6 +909,30 @@ await refreshServerInfo(serverId) + router.push('/server-health')} + testID="settings-server-health-row" + > + {t('help.serverHealth')} + + + router.push('/paired-devices')} + testID="settings-paired-devices-row" + > + {t('help.pairedDevices')} + + + router.push('/backup-restore')} + testID="settings-backup-restore-row" + > + {t('help.backupRestore')} + + router.push('/help-feedback')} @@ -1002,14 +1036,6 @@ function styles(theme: ReturnType) { cardGlass: { backgroundColor: 'transparent', }, - comingSoonOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: theme.colorMode === 'dark' ? 'rgba(0, 0, 0, 0.55)' : 'rgba(255, 255, 255, 0.65)', - }, segmentedControlGlass: { backgroundColor: 'transparent', }, diff --git a/components/RenderErrorBoundary.tsx b/components/RenderErrorBoundary.tsx new file mode 100644 index 00000000..e8913ac6 --- /dev/null +++ b/components/RenderErrorBoundary.tsx @@ -0,0 +1,94 @@ +import React, { Component, type ErrorInfo, type ReactNode } from 'react' +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native' +import i18n from '@/lib/i18n' +import { captureHandledError } from '@/services/sentry' +import { font, spacing } from '@/constants/theme' + +interface Props { + children: ReactNode + /** Optional raw text to show when the child tree throws. */ + rawFallback?: string + /** Sentry tag so crashes are attributable to the containment site. */ + tag?: string + onRetry?: () => void +} + +interface State { + hasError: boolean + error: Error | null +} + +/** + * Row/list-scoped error boundary. Isolates a bad message/line render so the + * rest of the session screen stays interactive. + */ +export class RenderErrorBoundary extends Component { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, _info: ErrorInfo): void { + try { + captureHandledError(error, { + tag: this.props.tag ?? 'render_error_boundary', + }) + } catch { + // Sentry must never break containment. + } + } + + private handleRetry = () => { + this.setState({ hasError: false, error: null }) + this.props.onRetry?.() + } + + render() { + if (this.state.hasError) { + const preview = (this.props.rawFallback ?? this.state.error?.message ?? '').trim() + return ( + + {i18n.t('common:renderError.title')} + {i18n.t('common:renderError.message')} + {preview ? ( + + {preview} + + ) : null} + + {i18n.t('common:button.retry')} + + + ) + } + return this.props.children + } +} + +const styles = StyleSheet.create({ + box: { + marginHorizontal: spacing.md, + marginVertical: spacing.xs, + padding: spacing.md, + borderRadius: 8, + borderWidth: 1, + borderColor: '#30363d', + backgroundColor: '#161b22', + gap: spacing.xs, + }, + title: { color: '#e6edf3', fontSize: font.sm, fontWeight: '600' }, + body: { color: '#8b949e', fontSize: font.xs }, + raw: { + color: '#8b949e', + fontSize: font.xs, + fontFamily: 'monospace', + marginTop: spacing.xs, + }, + retry: { alignSelf: 'flex-start', marginTop: spacing.xs, paddingVertical: 4 }, + retryText: { color: '#58a6ff', fontSize: font.sm, fontWeight: '500' }, +}) diff --git a/components/browse/RecentDirsModal.tsx b/components/browse/RecentDirsModal.tsx new file mode 100644 index 00000000..f970aa2b --- /dev/null +++ b/components/browse/RecentDirsModal.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { + Modal, + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + FlatList, +} from 'react-native' +import { useTranslation } from 'react-i18next' +import { ClockCounterClockwise, CaretRight, MagnifyingGlass, X } from 'phosphor-react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { font, radius, spacing, type Theme } from '@/constants/theme' +import { useTheme } from '@/contexts/ThemeContext' +import { MIN_TOUCH_TARGET } from '@/constants/a11y' + +export interface RecentDir { + path: string + name: string + lastUsedAt: string +} + +interface Props { + visible: boolean + dirs: RecentDir[] + onClose: () => void + onSelect: (dir: RecentDir) => void + disabled?: boolean +} + +export function RecentDirsModal({ visible, dirs, onClose, onSelect, disabled }: Props) { + const theme = useTheme() + const styles = useMemo(() => makeStyles(theme), [theme]) + const { t } = useTranslation(['browse', 'common']) + const [query, setQuery] = useState('') + + useEffect(() => { + if (visible) { + queueMicrotask(() => { + setQuery('') + }) + } + }, [visible]) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return dirs + return dirs.filter((dir) => dir.path.toLowerCase().includes(q)) + }, [dirs, query]) + + return ( + + + + {t('nav.allRecentDirs')} + + + + + + + + + + + item.path} + keyboardShouldPersistTaps="handled" + ListEmptyComponent={ + {t('nav.noMatchingDirs')} + } + renderItem={({ item }) => ( + onSelect(item)} + disabled={disabled} + testID={`recent-dir-row-${item.path}`} + > + + + + {item.name} + + + {item.path} + + + + + )} + /> + + + ) +} + +function makeStyles(theme: Theme) { + return StyleSheet.create({ + root: { + flex: 1, + backgroundColor: theme.bg.primary, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.border, + }, + title: { + color: theme.text.primary, + fontSize: font.lg, + fontWeight: '600', + }, + closeBtn: { + minWidth: MIN_TOUCH_TARGET, + minHeight: MIN_TOUCH_TARGET, + alignItems: 'center', + justifyContent: 'center', + }, + searchWrap: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: spacing.lg, + marginVertical: spacing.md, + paddingHorizontal: spacing.md, + gap: spacing.sm, + borderRadius: radius.md, + backgroundColor: theme.bg.secondary, + borderWidth: 1, + borderColor: theme.border, + }, + search: { + flex: 1, + paddingVertical: spacing.sm, + color: theme.text.primary, + fontSize: font.base, + }, + empty: { + color: theme.text.secondary, + fontSize: font.sm, + textAlign: 'center', + paddingVertical: spacing.xl, + paddingHorizontal: spacing.lg, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + gap: spacing.md, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.border, + }, + textWrap: { + flex: 1, + }, + name: { + color: theme.text.primary, + fontSize: font.base, + }, + path: { + color: theme.text.secondary, + fontSize: font.xs, + marginTop: 2, + }, + }) +} diff --git a/components/conversation/ConversationHistoryList.tsx b/components/conversation/ConversationHistoryList.tsx index cf6d5ada..8e2cde3e 100644 --- a/components/conversation/ConversationHistoryList.tsx +++ b/components/conversation/ConversationHistoryList.tsx @@ -25,6 +25,11 @@ import { useTheme } from '@/contexts/ThemeContext' // bugs). The two consumers differ only in whether native bottom-anchoring is // on (tail) or off with app-driven scrolling (anchored search). +// A newly-arrived message animates in only if it lands within this many rows of +// the tail — so a live append fades in, but a jump/backfill of older history +// does not animate a screenful at once. +const ANIMATE_TAIL_WINDOW = 8 + export interface ConversationHistoryListProps { messages: Message[] lastMessageId: string | undefined @@ -88,6 +93,44 @@ export const ConversationHistoryList = forwardRef, Convers const [showScrollTop, setShowScrollTop] = useState(false) const [showScrollBottom, setShowScrollBottom] = useState(false) + // Ids that should play the fade-in-from-bottom entrance. A message id not yet + // seen is new; it animates ONLY when it lands in the tail window (a live + // append), never at the head (older-history page load). The whole loaded + // history present on first render is seeded silently so nothing animates on a + // cold open. Ids are only ever ADDED — FadeInDown fires once on the row's + // mount, so keeping the id set-membership stable can't re-trigger it, and it + // keeps the row's component type (Animated.View) from flipping mid-animation. + // + // Held in state so a new tail row re-renders the list; `epoch` bumps only + // when the set grows, so renderItem's identity (and FlashList's cells) stay + // stable across the far more common no-new-message reloads. + const animateIdsRef = useRef>(new Set()) + const seededRef = useRef(false) + const [animateEpoch, setAnimateEpoch] = useState(0) + /* eslint-disable react-hooks/refs -- render-time seen/animate id cache; see note above */ + if (!seededRef.current) { + seededRef.current = true + animateIdsRef.current = new Set() // seed nothing → history renders silently + messages.forEach((m) => animateIdsRef.current.add(`seen:${m.id}`)) + } else { + const set = animateIdsRef.current + const tailStart = Math.max(0, messages.length - ANIMATE_TAIL_WINDOW) + let grew = false + messages.forEach((m, i) => { + const seenKey = `seen:${m.id}` + if (!set.has(seenKey)) { + set.add(seenKey) + if (i >= tailStart) { + set.add(m.id) + grew = true + } + } + }) + if (grew) setAnimateEpoch((e) => e + 1) + } + const animateIds = animateIdsRef.current + /* eslint-enable react-hooks/refs */ + const renderItem = useCallback( ({ item }: { item: Message }) => ( , Convers isLast={item.id === lastMessageId} highlight={highlight && item.messageIndex === highlightIndex ? highlight : undefined} onMatchLayout={onMatchLayout} + animateIn={animateIds.has(item.id)} /> ), - [lastMessageId, highlight, highlightIndex, onMatchLayout], + // animateIds is a stable ref; animateEpoch re-keys this only when it grew. + // eslint-disable-next-line react-hooks/exhaustive-deps + [lastMessageId, highlight, highlightIndex, onMatchLayout, animateEpoch], ) // Distinguish row shapes so FlashList only recycles cells of the same kind; diff --git a/components/conversation/DiffViewer.tsx b/components/conversation/DiffViewer.tsx index 2ce5a603..116e65fc 100644 --- a/components/conversation/DiffViewer.tsx +++ b/components/conversation/DiffViewer.tsx @@ -2,10 +2,12 @@ import React from 'react' import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated' +import { FileCode } from 'phosphor-react-native' import * as Clipboard from 'expo-clipboard' import { useRecyclingState } from '@shopify/flash-list' import { useTranslation } from 'react-i18next' import { dark, font, radius, spacing } from '@/constants/theme' +import { MAX_FONT_SIZE_MULTIPLIER_MONO, MIN_TOUCH_TARGET } from '@/constants/a11y' import type { DiffHunk } from '@/types/api' const COLLAPSE_THRESHOLD = 100 @@ -62,14 +64,11 @@ export function DiffViewer({ filename, hunks, language, recycleKey }: Props) { const copyPatch = () => Clipboard.setStringAsync(toPatch(filename, hunks)) - const ext = filename.split('.').pop() ?? '' - const fileIcon = ext === 'ts' || ext === 'tsx' ? '📘' : ext === 'go' ? '🐹' : ext === 'md' ? '📝' : '📄' - return ( - + - {fileIcon} - {filename} + + {filename} +{added} −{removed} @@ -135,7 +134,6 @@ const styles = StyleSheet.create({ padding: spacing.sm, backgroundColor: '#1c2128', }, - fileIcon: { fontSize: font.sm }, filename: { color: dark.text.primary, fontSize: font.sm, @@ -144,7 +142,7 @@ const styles = StyleSheet.create({ }, added: { color: dark.status.running, fontSize: font.sm, fontWeight: '600' }, removed: { color: dark.status.failed, fontSize: font.sm, fontWeight: '600' }, - copyBtn: { paddingHorizontal: spacing.sm, minHeight: 44, justifyContent: 'center' }, + copyBtn: { paddingHorizontal: spacing.sm, minHeight: MIN_TOUCH_TARGET, minWidth: MIN_TOUCH_TARGET, justifyContent: 'center' }, copyText: { color: dark.text.secondary, fontSize: font.xs }, collapseBtn: { padding: spacing.md, diff --git a/components/conversation/LiveConversationView.tsx b/components/conversation/LiveConversationView.tsx index f43bd50f..b343b953 100644 --- a/components/conversation/LiveConversationView.tsx +++ b/components/conversation/LiveConversationView.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Alert, ScrollView, StyleSheet, Text, Keyboard } from 'react-native' +import { Alert, StyleSheet, Text, Keyboard } from 'react-native' import { KeyboardAvoidingView } from 'react-native-keyboard-controller' import { FlashList, type FlashListRef } from '@shopify/flash-list' import { useQueryClient } from '@tanstack/react-query' @@ -15,25 +15,33 @@ import { MessageItem } from '@/components/conversation/MessageItem' import { ThinkingBubble } from '@/components/conversation/ThinkingBubble' import { stripAnsi } from '@/utils/stripAnsi' import { stripBoxDrawing } from '@/utils/stripBoxDrawing' +import { mergeLiveMessages } from '@/utils/mergeLiveMessages' import { ChatComposer } from '@/components/conversation/ChatComposer' import { SlashCommandBoard } from '@/components/shared/SlashCommandBoard' import { SlashCommandArgModal } from '@/components/shared/SlashCommandArgModal' import { PromptQueueSheet } from '@/components/queue/PromptQueueSheet' import { PlanPreviewSheet } from '@/components/queue/PlanPreviewSheet' import { wsManager } from '@/services/ws-client' +import { markSessionUsed } from '@/lib/sessionUsage' import type { Message } from '@/types/api' import { useTheme } from '@/contexts/ThemeContext' import { type Theme } from '@/constants/theme' +import type { ProviderName } from '@/constants/providers' +import { preferRawTerminal } from '@/lib/renderConfidence' +import { RenderErrorBoundary } from '@/components/RenderErrorBoundary' interface Props { serverId: string sessionId: string conversationId: string + provider?: ProviderName | string | null /** Disable the composer while the session's PTY is still waking up. */ disabled?: boolean /** Plan to preview, surfaced from the session screen's plan_ready listener. */ pendingPlan?: string | null onClosePlan?: () => void + /** Prefer raw terminal when chat normalization looks unreliable. */ + onPreferRawTerminal?: () => void } // Concatenate a user message's text blocks for echo matching. @@ -63,9 +71,11 @@ export function LiveConversationView({ serverId, sessionId, conversationId, + provider, disabled = false, pendingPlan = null, onClosePlan, + onPreferRawTerminal, }: Props) { const theme = useTheme() const styles = makeStyles(theme) @@ -113,17 +123,10 @@ export function LiveConversationView({ return remaining })() - // Order: historical → optimistic user bubble → live WS messages. Dedup by id - // last (uuid-less messages fall back to timestamp-type-role ids that can - // collide across REST/WS; duplicate FlashList keys trigger a render loop). - const allMessages = (() => { - const seen = new Set() - return [...orderedHistorical, ...stillPending, ...newLive].filter((m) => { - if (seen.has(m.id)) return false - seen.add(m.id) - return true - }) - })() + // Order: historical → optimistic user bubble → live WS messages. Dedup by + // uuid then id (shared with the read-only conversation view). newLive above is + // recomputed inside the helper — kept local here only for the echo matching. + const allMessages = mergeLiveMessages(orderedHistorical, liveMessages, stillPending) // Session status for thinking indicator const { data: session } = useSessionDetail(serverId, sessionId) @@ -142,7 +145,25 @@ export function LiveConversationView({ }, [serverId, sessionId, qc]) // PTY lines shown inside the thinking bubble while agent is running - const { lines: ptyLines, isStreaming } = useTerminalStream(serverId, sessionId) + const { lines: ptyLines, isStreaming, parseConfidence } = useTerminalStream( + serverId, + sessionId, + false, + provider, + ) + + useEffect(() => { + const decision = preferRawTerminal({ + sessionView: 'chat', + hasConversationId: true, + conversationMessageCount: allMessages.length, + ptyVisibleLineCount: ptyLines.length, + parseConfidence, + }) + if (decision.mode === 'terminal' && !decision.chatAuthoritative) { + onPreferRawTerminal?.() + } + }, [allMessages.length, ptyLines.length, parseConfidence, onPreferRawTerminal]) // Show thinking bubble whenever the session is running. Mid-turn assistant // messages (interim replies, sub-agent dispatches) land while Claude is @@ -182,6 +203,7 @@ export function LiveConversationView({ Alert.alert('Not connected', 'Waiting for connection — try again in a moment.') return } + markSessionUsed(sessionId) if (optimisticText) { setPendingSends((prev) => [...prev, makeOptimisticMessage(optimisticText)]) } @@ -213,6 +235,11 @@ export function LiveConversationView({ } = useComposerState({ serverId, sessionId, onSend: send }) // Auto-scroll to bottom when keyboard opens or app resumes with keyboard already up. + // New-message/thinking-bubble scrolling is left to FlashList's native + // maintainVisibleContentPosition bottom-anchoring below — a JS scrollToEnd + // fired from an effect races FlashList's cell measurement for the new row, + // landing short until a manual scroll forces a re-layout (see + // ConversationHistoryList's comment on this same hand-rolled machinery). useEffect(() => { const onShow = () => listRef.current?.scrollToEnd({ animated: true }) const subShow = Keyboard.addListener('keyboardDidShow', onShow) @@ -220,19 +247,6 @@ export function LiveConversationView({ return () => { subShow.remove(); subChange.remove() } }, []) - // Auto-scroll to bottom when a new message appears or the thinking bubble shows. - useEffect(() => { - if (allMessages.length > 0) { - listRef.current?.scrollToEnd({ animated: true }) - } - }, [allMessages.length]) - - useEffect(() => { - if (thinkingState === 'thinking') { - listRef.current?.scrollToEnd({ animated: true }) - } - }, [thinkingState]) - return ( m.id} renderItem={({ item, index }) => ( - + + + )} + maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 0.2, startRenderingFromBottom: true }} onLoad={() => listRef.current?.scrollToEnd({ animated: false })} ListEmptyComponent={ // A freshly-started / waiting_input session has no JSONL yet, so there @@ -322,13 +342,17 @@ function LivePtyPlaceholder({ lines, theme }: { lines: string[]; theme: Theme }) if (visibleLines.length === 0) return null const styles = makeStyles(theme) return ( - - {visibleLines.map((line, i) => ( - - {line} + `${index}:${item.slice(0, 24)}`} + renderItem={({ item }) => ( + + {item} - ))} - + )} + style={styles.ptyContainer} + contentContainerStyle={styles.ptyContent} + /> ) } diff --git a/components/conversation/LivePauseControl.tsx b/components/conversation/LivePauseControl.tsx new file mode 100644 index 00000000..2fb6facc --- /dev/null +++ b/components/conversation/LivePauseControl.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { Pressable, Text, StyleSheet } from 'react-native' +import { Pause, Play } from 'phosphor-react-native' +import { useTranslation } from 'react-i18next' +import { LiveDot } from '@/components/sessions/LiveDot' +import { font, spacing, type Theme } from '@/constants/theme' +import { useTheme } from '@/contexts/ThemeContext' + +interface Props { + paused: boolean + onToggle: () => void +} + +/** + * Header pill for a live conversation: a pulsing "Live" dot the user can tap to + * pause transcript updates (freezing the messages on screen) and tap again to + * resume. Rendered only while the session is actually live. + */ +export function LivePauseControl({ paused, onToggle }: Props) { + const { t } = useTranslation('conversation') + const theme = useTheme() + const styles = makeStyles(theme) + const color = paused ? theme.text.secondary : theme.status.running + const Icon = paused ? Play : Pause + + return ( + [styles.pill, { borderColor: color }, { opacity: pressed ? 0.6 : 1 }]} + > + + + {paused ? t('live.paused') : t('live.indicator')} + + + + ) +} + +function makeStyles(_theme: Theme) { + return StyleSheet.create({ + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + }, + label: { + fontSize: font.xs, + fontWeight: '600', + }, + }) +} diff --git a/components/conversation/MessageBubble.tsx b/components/conversation/MessageBubble.tsx index f534a6d4..0d25c2f4 100644 --- a/components/conversation/MessageBubble.tsx +++ b/components/conversation/MessageBubble.tsx @@ -24,6 +24,9 @@ interface Props { highlight?: string /** Set on the anchored search row: reports where the match sits inside the row. */ matchAnchor?: MatchAnchor + /** Set when nested inside MessageItem's toolContainer, which already applies its own + * marginVertical + gap — an outer margin here would double the spacing between rows. */ + noOuterMargin?: boolean } export interface MatchAnchor { @@ -320,7 +323,7 @@ function ContentBlock({ // Memoized: message objects are stable by reference for already-loaded pages // (adaptRawMessage output is reused between renders), so screen-level state // changes don't re-render — and re-highlight — every visible row. -export const MessageBubble = React.memo(function MessageBubble({ message, highlight, matchAnchor }: Props) { +export const MessageBubble = React.memo(function MessageBubble({ message, highlight, matchAnchor, noOuterMargin }: Props) { const { t } = useTranslation('conversation') const theme = useTheme() const isGlass = useIsGlass() @@ -328,7 +331,7 @@ export const MessageBubble = React.memo(function MessageBubble({ message, highli const isUser = message.role === 'user' return ( - + {!isUser && } {message.content.map((block, i) => ( @@ -348,6 +351,7 @@ function makeStyles(theme: Theme) { paddingHorizontal: spacing.md, marginVertical: spacing.xs, }, + containerNoMargin: { marginVertical: 0 }, containerUser: { alignItems: 'flex-end' }, containerAssistant: { alignItems: 'flex-start' }, bubble: { diff --git a/components/conversation/MessageItem.tsx b/components/conversation/MessageItem.tsx index e44cd4cb..f8f0dd70 100644 --- a/components/conversation/MessageItem.tsx +++ b/components/conversation/MessageItem.tsx @@ -1,5 +1,6 @@ import React, { useRef } from 'react' import { View, Text, StyleSheet } from 'react-native' +import Animated, { FadeInDown } from 'react-native-reanimated' import { useTranslation } from 'react-i18next' import { MessageBubble, type MatchAnchor } from '@/components/conversation/MessageBubble' import { ThinkingCard } from '@/components/conversation/ThinkingCard' @@ -8,6 +9,7 @@ import { DiffViewer } from '@/components/conversation/DiffViewer' import type { Message, MessageContent } from '@/types/api' import { useTheme } from '@/contexts/ThemeContext' import { font, spacing, type Theme } from '@/constants/theme' +import { useReducedMotion } from '@/hooks/useReducedMotion' export function renderContent( block: MessageContent, @@ -39,6 +41,7 @@ export const MessageItem = React.memo(function MessageItem({ isLast, highlight, onMatchLayout, + animateIn, }: { message: Message isLast?: boolean @@ -46,10 +49,13 @@ export const MessageItem = React.memo(function MessageItem({ highlight?: string /** Reports the highlighted match's y offset within this row, for anchored scrolling. */ onMatchLayout?: (messageIndex: number, y: number) => void + /** Play the fade-in-from-bottom entrance — set only on freshly-arrived tail rows. */ + animateIn?: boolean }) { const { t } = useTranslation('conversation') const theme = useTheme() const styles = makeStyles(theme) + const reduceMotion = useReducedMotion() const rowRef = useRef(null) const messageIndex = message.messageIndex const matchAnchor: MatchAnchor | undefined = @@ -67,9 +73,15 @@ export const MessageItem = React.memo(function MessageItem({ // search anchor in any fixture this app ships. const rowTestId = highlight ? 'search-anchor-message' : isLast ? 'conversation-last-message' : undefined + // Fade-in-from-bottom on freshly-arrived rows only. FadeInDown keyed on the + // message id so React runs it once per real message, never on FlashList cell + // recycle. Plain View elsewhere — history and scrolled-back rows never animate. + const Row = animateIn && !reduceMotion ? Animated.View : View + const entering = animateIn && !reduceMotion ? FadeInDown.duration(260).springify().damping(18) : undefined + if (hasToolOrDiff) { return ( - + {message.has_images ? ( {t('header.containsImage')} ) : null} @@ -83,23 +95,24 @@ export const MessageItem = React.memo(function MessageItem({ recycleKey={message.id} highlight={highlight} matchAnchor={matchAnchor} + noOuterMargin /> ) } return renderContent(block, i, message.id, highlight, matchAnchor) })} - + ) } if (message.content.length === 0) return null return ( - + {message.has_images ? ( {t('header.containsImage')} ) : null} - + ) }) diff --git a/components/conversation/ThinkingBubble.tsx b/components/conversation/ThinkingBubble.tsx index cac0c325..d1aeb4f1 100644 --- a/components/conversation/ThinkingBubble.tsx +++ b/components/conversation/ThinkingBubble.tsx @@ -6,6 +6,7 @@ import { parseQuestionBlock, type QuestionBlock } from '@/utils/parseQuestionBlo import { stripAnsi } from '@/utils/stripAnsi' import { stripBoxDrawing } from '@/utils/stripBoxDrawing' import { QuestionCard } from '@/components/terminal/QuestionCard' +import { SkeletonBox } from '@/components/ui/Skeleton' function DotsAnimation({ style, color }: { style?: object; color: string }) { // useMemo so Animated.Value instances are stable across re-renders @@ -149,7 +150,16 @@ export function ThinkingBubble({ lines, isStreaming, fadingOut = false, onFadeOu ) : null} {(isStreaming || !hasLines) ? ( - ) : null} + ) : ( + // Agent is still working but the PTY has gone quiet. Claude only + // repaints when it has something to draw, so a silent think (30s+ is + // routine) leaves the lines above frozen and nothing moving — which + // reads as a dead session. A skeleton keeps the turn visibly alive. + + + + + )} ) @@ -190,5 +200,12 @@ function makeStyles(theme: Theme) { dotsWithLines: { marginTop: spacing.xs, }, + skeleton: { + marginTop: spacing.xs, + minWidth: 140, + }, + skeletonLineGap: { + marginTop: spacing.xs, + }, }) } diff --git a/components/onboarding/OnboardingNavigator.tsx b/components/onboarding/OnboardingNavigator.tsx index adbc34a9..1dc35773 100644 --- a/components/onboarding/OnboardingNavigator.tsx +++ b/components/onboarding/OnboardingNavigator.tsx @@ -6,9 +6,11 @@ import type { PairResult } from '@/hooks/useTBPair' import { OnboardingShell } from './OnboardingShell' import { ConnectStep } from './steps/ConnectStep' import { DoneStep } from './steps/DoneStep' +import { NotificationsStep } from './steps/NotificationsStep' import { WelcomeStep } from './steps/WelcomeStep' -export const TOTAL_STEPS = 3 +// Welcome → Connect → Notifications → Done (redesign: Notifications after pair) +export const TOTAL_STEPS = 4 export const ONBOARDED_KEY = 'threadbase_onboarded' const PAIRED_TOKEN_HASH_KEY = 'threadbase_paired_token_hash' @@ -42,7 +44,7 @@ function derivePort(url: string): string { if (parsed.port) return parsed.port return parsed.protocol === 'https:' ? '443' : '80' } catch { - return '7331' + return '8766' } } @@ -62,10 +64,19 @@ export function OnboardingNavigator({ onDone }: Props) { const onNext = useCallback(() => { setIndex((curr) => { + // Connect step: swipe/forward must not jump to Done unpaired. + if (curr === 1 && !paired) return curr if (curr >= TOTAL_STEPS - 1) return curr setDirection(1) return curr + 1 }) + }, [paired]) + + // ConnectStep calls this after a successful pair — bypass the unpaired guard + // (paired state may not have flushed yet when onAdvance runs). + const advanceAfterPair = useCallback(() => { + setDirection(1) + setIndex(2) }, []) const onBack = useCallback(() => { @@ -76,9 +87,11 @@ export function OnboardingNavigator({ onDone }: Props) { }) }, []) + // Skip / pair-later: jump to Done, skipping Notifications when unpaired. const onSkip = useCallback(() => { + if (index !== 1) return goto(TOTAL_STEPS - 1) - }, [goto]) + }, [goto, index]) const handlePaired = useCallback((result: PairResult) => { setPaired(result) @@ -87,7 +100,11 @@ export function OnboardingNavigator({ onDone }: Props) { const handleEnter = useCallback(async () => { try { if (paired) { - await addServer(paired.url, paired.apiKey) + await addServer(paired.url, paired.apiKey, paired.label, { + deviceId: paired.deviceId, + deviceToken: paired.deviceToken, + capabilities: paired.capabilities, + }) await SecureStore.setItemAsync( PAIRED_TOKEN_HASH_KEY, hashToken(paired.apiKey), @@ -107,16 +124,20 @@ export function OnboardingNavigator({ onDone }: Props) { onNext={onNext} onBack={onBack} onSkip={onSkip} + showSkip={index === 1} + skipLabelKey="shell.pairLater" > {index === 0 && } {index === 1 && ( - + )} - {index === 2 && ( + {index === 2 && } + {index === 3 && ( )} diff --git a/components/onboarding/OnboardingShell.tsx b/components/onboarding/OnboardingShell.tsx index c7f494bd..3fcc6134 100644 --- a/components/onboarding/OnboardingShell.tsx +++ b/components/onboarding/OnboardingShell.tsx @@ -5,6 +5,7 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import Svg, { Path } from 'react-native-svg' import { useTranslation } from 'react-i18next' +import type { ParseKeys } from 'i18next' import { PagerDots } from './components/PagerDots' import { colors, fonts } from './theme' import { flexRow } from '@/lib/rtl' @@ -16,6 +17,10 @@ interface Props { onNext: () => void onBack: () => void onSkip: () => void + /** When false, the Skip chrome control is hidden (Welcome). Default: index < total - 1. */ + showSkip?: boolean + /** i18n key under `onboarding` for the skip label. Default: shell.skip */ + skipLabelKey?: ParseKeys<'onboarding'> children: React.ReactNode } @@ -26,6 +31,8 @@ export function OnboardingShell({ onNext, onBack, onSkip, + showSkip: showSkipProp, + skipLabelKey = 'shell.skip', children, }: Props) { const insets = useSafeAreaInsets() @@ -42,7 +49,7 @@ export function OnboardingShell({ .runOnJS(true) const showBack = index > 0 - const showSkip = index < total - 1 + const showSkip = showSkipProp ?? index < total - 1 const Entering = direction === -1 ? SlideInLeft : SlideInRight return ( @@ -67,7 +74,7 @@ export function OnboardingShell({ )} {showSkip ? ( - {t('shell.skip')} + {t(skipLabelKey)} ) : ( diff --git a/components/onboarding/steps/ConnectStep.tsx b/components/onboarding/steps/ConnectStep.tsx index 3ac85f84..1ee64eb5 100644 --- a/components/onboarding/steps/ConnectStep.tsx +++ b/components/onboarding/steps/ConnectStep.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' import { + Linking, StyleSheet, Text, TouchableOpacity, @@ -11,8 +12,9 @@ import * as Clipboard from 'expo-clipboard' import { useTranslation } from 'react-i18next' import { useTBPair, type PairResult, type PairLogKind } from '@/hooks/useTBPair' import { PairScannerModal } from '@/components/pair/PairScannerModal' -import { ServerFormFields, splitUrl } from '@/components/servers/ServerFormFields' -import type { ExchangeResult } from '@/services/pair-exchange' +import { ServerFormFields } from '@/components/servers/ServerFormFields' +import { classifyPairCredential, type ExchangeResult } from '@/services/pair-exchange' +import { isValidHttpServerUrl } from '@/lib/serverUrl' import { PrimaryButton } from '../components/PrimaryButton' import { TerminalCard } from '../components/TerminalCard' import { InfoTooltip } from '../components/InfoTooltip' @@ -88,20 +90,37 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { const [protocol, setProtocol] = useState<'http' | 'https'>('http') const [urlHost, setUrlHost] = useState('') const [token, setToken] = useState('') + const [label, setLabel] = useState('') + const [urlError, setUrlError] = useState(null) const [mode, setMode] = useState('choose') const [scannerOpen, setScannerOpen] = useState(false) const { phase, log, pair } = useTBPair() - const valid = urlHost.trim().length > 0 && token.length >= 8 + // A pasted threadbase:// pair URI carries its own server URL. + const credentialKind = classifyPairCredential(token) + const valid = + token.trim().length >= 8 && + (credentialKind === 'pair-uri' || urlHost.trim().length > 0) const busy = phase !== 'idle' && phase !== 'err' const handleConnect = () => { if (!valid || busy) return + if (credentialKind !== 'pair-uri') { + const fullUrl = `${protocol}://${urlHost.trim()}` + if (!isValidHttpServerUrl(fullUrl)) { + setUrlError(t('connect.invalidUrl')) + return + } + } + setUrlError(null) pair({ - url: `${protocol}://${urlHost.trim()}`, + url: urlHost.trim() ? `${protocol}://${urlHost.trim()}` : '', token, onSuccess: (result) => { - onPaired(result) + onPaired({ + ...result, + label: label.trim() || result.label, + }) onAdvance() }, }) @@ -109,19 +128,17 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { const handleScanSuccess = (result: ExchangeResult) => { setScannerOpen(false) - const { protocol: p, host } = splitUrl(result.url) - setProtocol(p) - setUrlHost(host) - setToken(result.apiKey) - setMode('manual') - pair({ + // QR exchange already sealed the API key — advance immediately instead of + // flipping to manual mode and re-running the handshake theater. + onPaired({ url: result.url, - token: result.apiKey, - onSuccess: (r) => { - onPaired(r) - onAdvance() - }, + apiKey: result.apiKey, + label: result.machineName ?? undefined, + deviceId: result.deviceId ?? undefined, + deviceToken: result.deviceToken ?? undefined, + capabilities: result.capabilities ?? undefined, }) + onAdvance() } if (mode === 'choose') { @@ -130,13 +147,20 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { {t('connect.eyebrow')} {t('connect.headline')} {t('connect.modeBlurb')} + {t('connect.connectivityHint')} setMode('qr-explain')} activeOpacity={0.85} + accessibilityRole="button" + accessibilityLabel={t('connect.scanQr')} > - {t('connect.scanQr')} + + {t('connect.scanQr')} + {t('connect.recommended')} + {t('connect.scanQrBody')} @@ -145,6 +169,8 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { style={styles.modeCard} onPress={() => setMode('manual')} activeOpacity={0.85} + accessibilityRole="button" + accessibilityLabel={t('connect.pasteCredentials')} > {t('connect.pasteCredentials')} {t('connect.pasteCredentialsBody')} @@ -156,30 +182,20 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { if (mode === 'qr-explain') { return ( + setMode('choose')} + style={styles.linkBtnTop} + > + {t('connect.backToOptions')} + {t('connect.qrEyebrow')} {t('connect.qrHeadline')} - - {/* eslint-disable-next-line i18next/no-literal-string */} - 1.{' '}On your server, run{' '} - {/* eslint-disable-next-line i18next/no-literal-string */} - tb pair. A QR will print to the terminal. - - - {/* eslint-disable-next-line i18next/no-literal-string */} - 2.{' '}Tap{' '} - {/* eslint-disable-next-line i18next/no-literal-string */} - {t('connect.openCamera')} below. Threadbase will ask - permission to use the camera — that's only used to read the QR. - - - {/* eslint-disable-next-line i18next/no-literal-string */} - 3.{' '}Point your phone at the QR. The pair token is - valid for 3 minutes; if it expires, just run{' '} - {/* eslint-disable-next-line i18next/no-literal-string */} - tb pair again. - + {t('connect.step1')} + {t('connect.step2')} + {t('connect.step3')} @@ -206,6 +222,13 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { keyboardShouldPersistTaps="handled" bottomOffset={16} > + setMode('choose')} + style={styles.linkBtnTop} + > + {t('connect.backToOptions')} + {t('connect.eyebrow')} {t('connect.headline')} @@ -222,10 +245,15 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { { + setUrlError(null) + setUrlHost(v) + }} apiKey={token} onApiKeyChange={setToken} keyFieldLabel={t('connect.manualToken')} @@ -244,7 +272,7 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { linkUrl="https://github.com/RonenMars/threadbase-streamer#mobile-pairing" > {/* eslint-disable-next-line i18next/no-literal-string, react-native/no-raw-text */} - A short-lived token printed by tb pair. Valid for 3 minutes — run tb pair again if it expires. + A short-lived pt_ token or the full threadbase:// link from tb pair. Valid for 3 minutes — run tb pair again if it expires. Long-lived tb_ API keys also work. } editable={!busy} @@ -253,6 +281,27 @@ export function ConnectStep({ onPaired, onAdvance }: Props) { onSubmitEditing={handleConnect} /> + {urlError ? ( + + {urlError} + + ) : null} + + {phase === 'err' ? ( + { + void Linking.openURL( + 'mailto:ronenmars@gmail.com?subject=Threadbase%20Pairing%20Help', + ) + }} + style={styles.supportLink} + accessibilityRole="link" + > + {t('connect.contactSupport')} + + ) : null} + {log.length > 0 && ( {log.map((ln, i) => ( @@ -368,6 +417,13 @@ const styles = StyleSheet.create({ fontFamily: fonts.mono, fontSize: 12.5, lineHeight: 19, + marginBottom: 10, + }, + connectivityHint: { + color: colors.fg4, + fontFamily: fonts.mono, + fontSize: 12, + lineHeight: 18, marginBottom: 16, }, modeCard: { @@ -377,12 +433,31 @@ const styles = StyleSheet.create({ padding: 14, marginBottom: 12, }, + modeCardPrimary: { + borderColor: colors.blue400, + backgroundColor: 'rgba(96, 165, 250, 0.08)', + }, + modeCardHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginBottom: 4, + }, + recommendedBadge: { + color: colors.blue400, + fontFamily: fonts.mono, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.4, + textTransform: 'uppercase', + }, modeCardTitle: { color: colors.fg0, fontFamily: fonts.sans, fontSize: 17, fontWeight: '600', - marginBottom: 4, + flexShrink: 1, }, modeCardBody: { color: colors.fg3, @@ -390,6 +465,16 @@ const styles = StyleSheet.create({ fontSize: 12, lineHeight: 18, }, + urlError: { + color: colors.red400, + fontFamily: fonts.mono, + fontSize: 12, + marginTop: 8, + }, + supportLink: { + alignSelf: 'flex-start', + paddingVertical: 8, + }, explainStep: { color: colors.fg2, fontFamily: fonts.mono, diff --git a/components/onboarding/steps/DoneStep.tsx b/components/onboarding/steps/DoneStep.tsx index baf0104a..36030557 100644 --- a/components/onboarding/steps/DoneStep.tsx +++ b/components/onboarding/steps/DoneStep.tsx @@ -23,15 +23,20 @@ interface Props { onEnter: () => void serverHost?: string serverPort?: string | number + serverLabel?: string } -export function DoneStep({ onEnter, serverHost, serverPort }: Props) { +export function DoneStep({ onEnter, serverHost, serverPort, serverLabel }: Props) { const { t } = useTranslation('onboarding') const paired = serverHost != null && serverPort != null const popScale = useSharedValue(0.8) const popOpacity = useSharedValue(0) const dotOpacity = useSharedValue(0.55) + const pairedPill = serverLabel + ? t('done.pillPairedNamed', { name: serverLabel, host: serverHost, port: serverPort }) + : t('done.pillPaired', { host: serverHost, port: serverPort }) + useEffect(() => { popScale.value = withSequence( withTiming(1.08, { duration: 220, easing: Easing.out(Easing.cubic) }), @@ -111,9 +116,7 @@ export function DoneStep({ onEnter, serverHost, serverPort }: Props) { )} - {paired - ? `paired · ${serverHost} · ${serverPort}` - : t('done.pillUnpaired')} + {paired ? pairedPill : t('done.pillUnpaired')} diff --git a/components/pair/PairScannerModal.tsx b/components/pair/PairScannerModal.tsx index 7e90c96c..cf468a35 100644 --- a/components/pair/PairScannerModal.tsx +++ b/components/pair/PairScannerModal.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { ActivityIndicator, + Linking, Modal, StyleSheet, Text, @@ -19,6 +20,7 @@ import { PairUriError, type ExchangeResult, } from '@/services/pair-exchange' +import { defaultPairDeviceName } from '@/services/pair-device-name' interface Props { visible: boolean @@ -75,7 +77,11 @@ export function PairScannerModal({ visible, onClose, onSuccess }: Props) { setPhase('exchanging') try { const parsed = parsePairUri(data) - const result = await exchangeToken({ url: parsed.url, token: parsed.token }) + const result = await exchangeToken({ + url: parsed.url, + token: parsed.token, + deviceName: defaultPairDeviceName(), + }) reset() onSuccess(result) onClose() @@ -105,6 +111,7 @@ export function PairScannerModal({ visible, onClose, onSuccess }: Props) { {t('scanner.permissionBody')} {permission.canAskAgain ? ( { const next = await requestPermission() @@ -114,7 +121,18 @@ export function PairScannerModal({ visible, onClose, onSuccess }: Props) { {t('scanner.allowCamera')} ) : ( - {t('scanner.permissionHint')} + <> + {t('scanner.permissionHint')} + { + void Linking.openSettings() + }} + > + {t('scanner.openSettings')} + + )} ) @@ -130,9 +148,19 @@ export function PairScannerModal({ visible, onClose, onSuccess }: Props) { {t('scanner.errorTitle')} {error} - + {t('scanner.tryAgain')} + { + void Linking.openURL( + 'mailto:ronenmars@gmail.com?subject=Threadbase%20Pairing%20Help', + ) + }} + > + {t('scanner.contactSupport')} + ) } else { @@ -163,7 +191,12 @@ export function PairScannerModal({ visible, onClose, onSuccess }: Props) { > {body} - + × @@ -245,5 +278,11 @@ function makeStyles(theme: Theme) { textAlign: 'center', lineHeight: 22, }, + supportLink: { + color: theme.text.accent, + fontSize: font.sm, + fontWeight: '600', + marginTop: spacing.sm, + }, }) } diff --git a/components/review/ReviewSheet.tsx b/components/review/ReviewSheet.tsx new file mode 100644 index 00000000..23614d98 --- /dev/null +++ b/components/review/ReviewSheet.tsx @@ -0,0 +1,307 @@ +import React, { useMemo, useState } from 'react' +import { + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, + TextInput, + ScrollView, + Alert, +} from 'react-native' +import { FlashList } from '@shopify/flash-list' +import { useTranslation } from 'react-i18next' +import { FileCode, CopySimple, PaperPlaneTilt, X } from 'phosphor-react-native' +import * as Clipboard from 'expo-clipboard' +import { DiffViewer } from '@/components/conversation/DiffViewer' +import { + buildReviewFromMessages, + formatReviewNote, + type ReviewFile, + type ReviewFileKind, +} from '@/lib/reviewFromConversation' +import type { Message } from '@/types/api' +import { useTheme } from '@/contexts/ThemeContext' +import { font, radius, spacing, type Theme } from '@/constants/theme' +import { MIN_TOUCH_TARGET } from '@/constants/a11y' + +type FilterKind = 'all' | ReviewFileKind + +interface Props { + visible: boolean + messages: Message[] + projectPath?: string + machineName?: string + canSendNote?: boolean + onClose: () => void + onSendNote?: (note: string) => void +} + +export function ReviewSheet({ + visible, + messages, + projectPath, + machineName, + canSendNote = false, + onClose, + onSendNote, +}: Props) { + const { t } = useTranslation('conversation') + const theme = useTheme() + const styles = makeStyles(theme) + const [filter, setFilter] = useState('all') + const [query, setQuery] = useState('') + const [selectedPath, setSelectedPath] = useState(null) + + const summary = useMemo(() => buildReviewFromMessages(messages), [messages]) + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + return summary.files.filter((f) => { + if (filter !== 'all' && f.kind !== filter) return false + if (q && !f.path.toLowerCase().includes(q)) return false + return true + }) + }, [summary.files, filter, query]) + + const selected: ReviewFile | undefined = selectedPath + ? summary.files.find((f) => f.path === selectedPath) + : undefined + + const handoffPacket = useMemo(() => { + const lines = [ + machineName ? `machine: ${machineName}` : null, + projectPath ? `project: ${projectPath}` : null, + `files: ${summary.files.length}`, + ...summary.files.slice(0, 30).map((f) => f.path), + ].filter(Boolean) + return lines.join('\n') + }, [machineName, projectPath, summary.files]) + + const copyHandoff = async () => { + await Clipboard.setStringAsync(handoffPacket) + } + + const sendNote = () => { + if (!onSendNote) return + const note = formatReviewNote(summary, selectedPath ?? undefined) + onSendNote(note) + } + + const kindLabel = (kind: ReviewFileKind) => { + if (kind === 'edited') return t('review.kindEdited') + if (kind === 'written') return t('review.kindWritten') + if (kind === 'diff') return t('review.kindDiff') + return t('review.kindUnknown') + } + + return ( + + + + {t('review.title')} + + + + + + + {t('review.summary', { + files: summary.files.length, + added: summary.totalAdded, + removed: summary.totalRemoved, + })} + + + {summary.incomplete ? ( + + {t('review.incompleteWarning')} + + ) : null} + {summary.hasOversized ? ( + {t('review.oversizedWarning')} + ) : null} + + + {(['all', 'edited', 'written', 'diff'] as FilterKind[]).map((k) => ( + setFilter(k)} + style={[styles.chip, filter === k && styles.chipActive]} + > + + {k === 'all' ? t('review.filterAll') : kindLabel(k)} + + + ))} + + + + + + + item.path} + ListEmptyComponent={ + {t('review.empty')} + } + renderItem={({ item }) => ( + setSelectedPath(item.path)} + style={[styles.row, selectedPath === item.path && styles.rowActive]} + testID="review-file-row" + > + + + {item.path} + + {t('review.fileMeta', { + kind: kindLabel(item.kind), + added: item.added, + removed: item.removed, + })} + + + + )} + /> + + + + {selected ? ( + + ) : ( + {t('review.selectFile')} + )} + + + + + + + {t('review.copyHandoff')} + + {canSendNote ? ( + { + if (!onSendNote) return + Alert.alert(t('review.sendNoteTitle'), t('review.sendNoteBody'), [ + { text: t('review.cancel'), style: 'cancel' }, + { text: t('review.send'), onPress: sendNote }, + ]) + }} + style={styles.actionBtn} + accessibilityRole="button" + testID="review-send-note" + > + + {t('review.sendNote')} + + ) : null} + + + + ) +} + +function makeStyles(theme: Theme) { + return StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg.primary, paddingTop: spacing.md }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + marginBottom: spacing.sm, + }, + title: { color: theme.text.primary, fontSize: font.lg, fontWeight: '700' }, + summary: { + color: theme.text.secondary, + fontSize: font.sm, + paddingHorizontal: spacing.md, + marginBottom: spacing.xs, + }, + warning: { + color: theme.text.warning, + fontSize: font.xs, + paddingHorizontal: spacing.md, + marginBottom: spacing.xs, + lineHeight: 16, + }, + filters: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + paddingHorizontal: spacing.md, + marginBottom: spacing.sm, + }, + chip: { + borderWidth: 1, + borderColor: theme.border, + borderRadius: radius.full ?? 999, + paddingHorizontal: 10, + paddingVertical: 4, + }, + chipActive: { backgroundColor: theme.bg.secondary, borderColor: theme.text.accent }, + chipText: { color: theme.text.secondary, fontSize: font.xs }, + chipTextActive: { color: theme.text.accent, fontWeight: '600' }, + search: { + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + borderWidth: 1, + borderColor: theme.border, + borderRadius: radius.md, + paddingHorizontal: spacing.sm, + paddingVertical: 8, + color: theme.text.primary, + fontSize: font.sm, + }, + body: { flex: 1 }, + listPane: { flex: 1, minHeight: 160, borderBottomWidth: 1, borderBottomColor: theme.border }, + diffPane: { flex: 1.2 }, + diffContent: { padding: spacing.sm }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + rowActive: { backgroundColor: theme.bg.secondary }, + rowText: { flex: 1, gap: 2 }, + path: { color: theme.text.primary, fontSize: font.sm, fontFamily: 'monospace' }, + meta: { color: theme.text.secondary, fontSize: font.xs }, + empty: { + color: theme.text.secondary, + fontSize: font.sm, + padding: spacing.lg, + textAlign: 'center', + }, + actions: { + flexDirection: 'row', + gap: spacing.md, + padding: spacing.md, + borderTopWidth: 1, + borderTopColor: theme.border, + }, + actionBtn: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + actionText: { color: theme.text.accent, fontSize: font.sm, fontWeight: '600' }, + }) +} diff --git a/components/servers/AddServerScreen.tsx b/components/servers/AddServerScreen.tsx index d13da80b..b5a2ebc3 100644 --- a/components/servers/AddServerScreen.tsx +++ b/components/servers/AddServerScreen.tsx @@ -32,7 +32,7 @@ export function AddServerScreen({ isAddingServer }: Props) { const navigation = useNavigation() const { addServer, displayedServerIds, setDisplayedServerIds } = useServersStore() const { addServerAction, setAddServerAction } = useSettingsStore() - const defaultUrl = process.env.EXPO_PUBLIC_DEFAULT_SERVER_URL ?? 'http://localhost:8766' + const defaultUrl = process.env.EXPO_PUBLIC_DEFAULT_SERVER_URL ?? '' const [protocol, setProtocol] = useState<'https' | 'http'>( defaultUrl.startsWith('https://') ? 'https' : 'http' ) @@ -86,7 +86,21 @@ export function AddServerScreen({ isAddingServer }: Props) { ) const connectWith = useCallback( - async ({ url, apiKey: keyArg, label: labelArg }: { url: string; apiKey: string; label?: string }) => { + async ({ + url, + apiKey: keyArg, + label: labelArg, + deviceId, + deviceToken, + capabilities, + }: { + url: string + apiKey: string + label?: string + deviceId?: string + deviceToken?: string + capabilities?: import('@/types/devices').DeviceCapability[] + }) => { setError(null) setLoading(true) @@ -98,7 +112,11 @@ export function AddServerScreen({ isAddingServer }: Props) { if (!res.ok) throw new NetworkError(`HTTP ${res.status}`) await res.json() - const addResult = await addServer(url, keyArg, labelArg || undefined) + const addResult = await addServer(url, keyArg, labelArg || undefined, { + deviceId, + deviceToken, + capabilities, + }) if (typeof addResult !== 'string') { setError('This server is already in your list.') return @@ -150,7 +168,14 @@ export function AddServerScreen({ isAddingServer }: Props) { setServerUrl(stripped) setApiKey(result.apiKey) setLabel(labelGuess) - await connectWith({ url: result.url, apiKey: result.apiKey, label: labelGuess }) + await connectWith({ + url: result.url, + apiKey: result.apiKey, + label: labelGuess, + deviceId: result.deviceId ?? undefined, + deviceToken: result.deviceToken ?? undefined, + capabilities: result.capabilities ?? undefined, + }) } return ( diff --git a/components/servers/CacheAlertBanner.tsx b/components/servers/CacheAlertBanner.tsx new file mode 100644 index 00000000..b0b0aae0 --- /dev/null +++ b/components/servers/CacheAlertBanner.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { StyleSheet, Text, TouchableOpacity } from 'react-native' +import { WarningCircle } from 'phosphor-react-native' +import { useTranslation } from 'react-i18next' +import { type Theme, font, spacing } from '@/constants/theme' +import { useTheme } from '@/contexts/ThemeContext' +import { useServersStore } from '@/stores/servers' + +interface Props { + onPress: () => void +} + +export function CacheAlertBanner({ onPress }: Props) { + const theme = useTheme() + const styles = makeStyles(theme) + const { t } = useTranslation('servers') + const servers = useServersStore((s) => s.servers) + const displayedServerIds = useServersStore((s) => s.displayedServerIds) + const cacheAlert = useServersStore((s) => s.cacheAlert) + + const alertServerId = displayedServerIds.find((id) => cacheAlert[id]?.severity === 'low') + const alert = alertServerId ? cacheAlert[alertServerId] : null + + if (!alert || !alertServerId) return null + + const serverLabel = servers[alertServerId]?.label || servers[alertServerId]?.url || alertServerId + + return ( + + + + {t('cacheAlert.bannerTitle', { count: alert.missingCount, server: serverLabel })} + + + ) +} + +function makeStyles(theme: Theme) { + return StyleSheet.create({ + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: theme.bg.secondary, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.border, + }, + title: { + flex: 1, + color: theme.text.primary, + fontSize: font.sm, + lineHeight: 18, + }, + }) +} diff --git a/components/servers/CacheAlertModal.tsx b/components/servers/CacheAlertModal.tsx new file mode 100644 index 00000000..9270c5ed --- /dev/null +++ b/components/servers/CacheAlertModal.tsx @@ -0,0 +1,362 @@ +import React, { useState } from 'react' +import { View, Text, TouchableOpacity, StyleSheet, Modal, Pressable, ScrollView } from 'react-native' +import { WarningCircle, CheckCircle, CircleIcon } from 'phosphor-react-native' +import { useTranslation } from 'react-i18next' +import { getCacheAlert, resolveCacheAlert } from '@/services/api-client' +import { useServersStore } from '@/stores/servers' +import type { CacheAlertResolveAction } from '@/types/api' +import { type Theme, font, radius, spacing } from '@/constants/theme' +import { useTheme, useIsGlass } from '@/contexts/ThemeContext' +import { GlassFill } from '@/components/ui/GlassFill' +import { clearServerConversationAndSessionState, queryClient } from '@/services/query-client' + +interface Props { + visible: boolean + serverId: string | null + onClose: () => void + onResolved: (backupPath?: string) => void +} + +const DESTRUCTIVE_ACTIONS: CacheAlertResolveAction[] = ['prune_all', 'prune_selected', 'reset_rescan'] + +export function CacheAlertModal({ visible, serverId, onClose, onResolved }: Props) { + const { t } = useTranslation('servers') + const theme = useTheme() + const isGlass = useIsGlass() + const styles = makeStyles(theme) + const servers = useServersStore((s) => s.servers) + const cacheAlert = useServersStore((s) => s.cacheAlert) + const setCacheAlert = useServersStore((s) => s.setCacheAlert) + + const alert = serverId ? cacheAlert[serverId] : null + const [pendingAction, setPendingAction] = useState(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [submitting, setSubmitting] = useState(false) + const [selectError, setSelectError] = useState(false) + + // Reset transient UI state whenever a different alert is shown, without an + // effect: adjust state during render per + // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [shownFingerprint, setShownFingerprint] = useState(alert?.fingerprint) + if (alert?.fingerprint !== shownFingerprint) { + setShownFingerprint(alert?.fingerprint) + setPendingAction(null) + setSelectedIds(new Set()) + setSubmitting(false) + setSelectError(false) + } + + if (!visible || !serverId || !alert) return null + + const serverLabel = servers[serverId]?.label || servers[serverId]?.url || serverId + const missing = alert.missing ?? [] + + const toggleId = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const refetchAlert = async () => { + const fresh = await getCacheAlert(serverId) + setCacheAlert(serverId, fresh) + } + + const submit = async (action: CacheAlertResolveAction) => { + setSubmitting(true) + try { + const result = await resolveCacheAlert(serverId, { + fingerprint: alert.fingerprint, + action, + ids: action === 'prune_selected' ? Array.from(selectedIds) : undefined, + }) + if (!result.ok) { + // Fingerprint changed since we fetched — refetch and let the user re-decide. + await refetchAlert() + setPendingAction(null) + return + } + if (DESTRUCTIVE_ACTIONS.includes(action) && !('alreadyResolved' in result)) { + clearServerConversationAndSessionState(serverId) + void queryClient.invalidateQueries({ queryKey: ['conversations-eager'] }) + void queryClient.invalidateQueries({ queryKey: ['conversations'] }) + void queryClient.invalidateQueries({ queryKey: ['sessions-eager'] }) + void queryClient.invalidateQueries({ queryKey: ['sessions'] }) + } + if ('alreadyResolved' in result) { + onResolved() + } else { + onResolved(result.backupPath) + } + } finally { + setSubmitting(false) + } + } + + const handleActionPress = (action: CacheAlertResolveAction) => { + if (action === 'prune_selected' && selectedIds.size === 0) { + setSelectError(true) + return + } + setSelectError(false) + if (DESTRUCTIVE_ACTIONS.includes(action)) { + setPendingAction(action) + } else { + void submit(action) + } + } + + const confirmMessage = pendingAction === 'prune_all' + ? t('cacheAlert.confirmPruneAll', { count: alert.missingCount }) + : pendingAction === 'prune_selected' + ? t('cacheAlert.confirmPruneSelected', { count: selectedIds.size }) + : pendingAction === 'reset_rescan' + ? t('cacheAlert.confirmResetRescan') + : '' + + return ( + + + {}}> + + + + + {t('cacheAlert.title', { + missing: alert.missingCount, + total: alert.totalRows, + server: serverLabel, + })} + + + + {alert.severity === 'high' ? ( + {t('cacheAlert.backupHint')} + ) : null} + + {pendingAction ? ( + + {t('cacheAlert.confirmTitle')} + {confirmMessage} + + setPendingAction(null)} + disabled={submitting} + > + {t('cacheAlert.confirmCancel')} + + submit(pendingAction)} + disabled={submitting} + > + + {t('cacheAlert.confirmProceed')} + + + + + ) : ( + <> + {missing.length > 0 ? ( + <> + + setSelectedIds(new Set(missing.map((m) => m.id)))}> + {t('cacheAlert.selectAll')} + + setSelectedIds(new Set())}> + {t('cacheAlert.selectNone')} + + + + {missing.map((item) => { + const selected = selectedIds.has(item.id) + return ( + toggleId(item.id)} + accessibilityRole="checkbox" + accessibilityState={{ checked: selected }} + > + {selected + ? + : } + + {item.title || item.filePath} + + + ) + })} + + {selectError ? ( + {t('cacheAlert.selectAtLeastOne')} + ) : null} + + ) : null} + + + handleActionPress('prune_all')} + disabled={submitting} + > + {t('cacheAlert.actionPruneAll')} + + {missing.length > 0 ? ( + handleActionPress('prune_selected')} + disabled={submitting} + > + {t('cacheAlert.actionPruneSelected')} + + ) : null} + handleActionPress('reset_rescan')} + disabled={submitting} + > + {t('cacheAlert.actionResetRescan')} + + handleActionPress('ignore')} + disabled={submitting} + > + {t('cacheAlert.actionIgnore')} + + + + )} + + + + ) +} + +function makeStyles(theme: Theme) { + return StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.55)', + justifyContent: 'flex-end', + paddingBottom: 40, + paddingHorizontal: spacing.md, + }, + sheet: { + backgroundColor: theme.bg.secondary, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: theme.border, + padding: spacing.md, + gap: spacing.sm, + maxHeight: '80%', + }, + sheetGlass: { + backgroundColor: 'transparent', + overflow: 'hidden', + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + }, + title: { + flex: 1, + color: theme.text.primary, + fontSize: font.base, + fontWeight: '600', + lineHeight: 20, + }, + backupHint: { + color: theme.text.secondary, + fontSize: font.xs, + lineHeight: 16, + }, + selectRow: { + flexDirection: 'row', + gap: spacing.md, + }, + selectLink: { + color: theme.text.accent, + fontSize: font.xs, + fontWeight: '500', + }, + list: { + maxHeight: 200, + }, + listItem: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.xs, + }, + listItemText: { + flex: 1, + color: theme.text.primary, + fontSize: font.sm, + }, + selectErrorText: { + color: theme.text.danger, + fontSize: font.xs, + }, + actions: { + gap: spacing.xs, + marginTop: spacing.xs, + }, + actionBtn: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + backgroundColor: theme.bg.card, + borderWidth: 1, + borderColor: theme.border, + borderRadius: radius.md, + alignItems: 'center', + }, + actionText: { + color: theme.text.primary, + fontSize: font.base, + fontWeight: '500', + }, + confirmBlock: { + gap: spacing.sm, + }, + confirmTitle: { + color: theme.text.primary, + fontSize: font.base, + fontWeight: '600', + }, + confirmMessage: { + color: theme.text.secondary, + fontSize: font.sm, + lineHeight: 19, + }, + confirmActions: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.xs, + }, + confirmBtn: { + flex: 1, + paddingVertical: spacing.sm, + alignItems: 'center', + borderWidth: 1, + borderColor: theme.border, + borderRadius: radius.md, + backgroundColor: theme.bg.card, + }, + confirmBtnDestructive: { + borderColor: theme.text.danger, + }, + confirmBtnText: { + color: theme.text.primary, + fontSize: font.base, + fontWeight: '600', + }, + }) +} diff --git a/components/servers/CacheAlertSync.tsx b/components/servers/CacheAlertSync.tsx new file mode 100644 index 00000000..11a0c069 --- /dev/null +++ b/components/servers/CacheAlertSync.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { getCacheAlert } from '@/services/api-client' +import { useServersStore } from '@/stores/servers' + +// Fetches GET /api/cache/alert for one server and feeds the result through the +// same setCacheAlert/clearCacheAlert reducers as the WS path, so a client that +// missed the WS unicast (e.g. backgrounded when it arrived) still converges. +// refetchOnWindowFocus (react-query default) covers the foreground case. +function CacheAlertSyncEntry({ serverId, isConnected }: { serverId: string; isConnected: boolean }) { + const setCacheAlert = useServersStore((s) => s.setCacheAlert) + const clearCacheAlert = useServersStore((s) => s.clearCacheAlert) + const { data } = useQuery({ + queryKey: ['cache-alert', serverId], + queryFn: () => getCacheAlert(serverId), + enabled: isConnected, + meta: { persist: false }, + }) + + useEffect(() => { + if (data === undefined) return + if (data) { + setCacheAlert(serverId, data) + } else { + const current = useServersStore.getState().cacheAlert[serverId] + if (current) clearCacheAlert(serverId, current.fingerprint) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, serverId]) + + return null +} + +/** Mount once near the app root. Syncs GET /api/cache/alert for every connected server. */ +export function CacheAlertSync() { + const servers = useServersStore((s) => s.servers) + const activeServerIds = useServersStore((s) => s.activeServerIds) + + return ( + <> + {activeServerIds.map((serverId) => ( + + ))} + + ) +} diff --git a/components/servers/FilterSortSheet.tsx b/components/servers/FilterSortSheet.tsx index 0113ee7c..86b4775a 100644 --- a/components/servers/FilterSortSheet.tsx +++ b/components/servers/FilterSortSheet.tsx @@ -55,6 +55,8 @@ export const ALL_STATUSES: SessionStatus[] = ['running', 'waiting_input', 'idle' const DEFAULT_SORT_BY: SortBy = 'lastActivity' const DEFAULT_SORT_ORDER: SortOrder = 'desc' +const DEFAULT_SESSIONS_LAYOUT: SessionsLayout = 'classic' + function isDefault( sortBy: SortBy, sortOrder: SortOrder, @@ -69,7 +71,7 @@ function isDefault( sortOrder === DEFAULT_SORT_ORDER && selectedStatuses.length === ALL_STATUSES.length && displayedServerIds.length === activeServerIds.length && - sessionsLayout === 'tree' && + sessionsLayout === DEFAULT_SESSIONS_LAYOUT && providerFilter === undefined ) } @@ -125,7 +127,7 @@ export function FilterSortSheet({ onChangeSortOrder(DEFAULT_SORT_ORDER) onChangeStatuses(ALL_STATUSES) onChangeProviderFilter(undefined) - setSessionsLayout('tree') + setSessionsLayout(DEFAULT_SESSIONS_LAYOUT) if (showServerFilter) setDisplayedServerIds(activeServerIds) } @@ -153,7 +155,14 @@ export function FilterSortSheet({ > - + {t('filter.close')} @@ -171,7 +180,9 @@ export function FilterSortSheet({ onPress={() => setSessionsLayout(value)} style={[styles.chip, selected && styles.chipSelected]} accessibilityRole="button" + accessibilityLabel={label} accessibilityState={{ selected }} + testID={`layout-option-${value}`} > diff --git a/components/servers/ServerClaudeFlagsSection.tsx b/components/servers/ServerClaudeFlagsSection.tsx new file mode 100644 index 00000000..5d5c061b --- /dev/null +++ b/components/servers/ServerClaudeFlagsSection.tsx @@ -0,0 +1,266 @@ +import React, { useMemo, useState } from 'react' +import { + ActivityIndicator, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { useTranslation } from 'react-i18next' +import { useTheme } from '@/contexts/ThemeContext' +import { font, radius, spacing, type Theme } from '@/constants/theme' +import { useClaudeFlags, useUpdateClaudeFlags } from '@/hooks/useClaudeFlags' +import { claudeFlagValueRisk } from '@/types/api' +import type { ClaudeFlagDefinition, ClaudeFlagValue, ClaudeFlagValues } from '@/types/api' +import { confirmDangerousChange } from '@/utils/confirmDangerousChange' + +interface Props { + serverId: string +} + +/** Lists render as comma-separated text; empty entries are dropped on parse. */ +function valueToText(value: ClaudeFlagValue | undefined): string { + if (value === undefined) return '' + if (Array.isArray(value)) return value.join(', ') + return String(value) +} + +function textToValue(def: ClaudeFlagDefinition, text: string): ClaudeFlagValue | undefined { + const trimmed = text.trim() + if (!trimmed) return undefined + if (def.valueType === 'list') { + const items = trimmed.split(',').map((s) => s.trim()).filter(Boolean) + return items.length > 0 ? items : undefined + } + return trimmed +} + +/** + * Per-server Claude CLI flags. + * + * Renders generically from the registry the SERVER supplies, so a streamer that + * knows about a newer flag needs no app update. Hidden entirely when the server + * predates the feature (query resolves to null). + */ +export function ServerClaudeFlagsSection({ serverId }: Props) { + const { t } = useTranslation(['servers', 'common']) + const theme = useTheme() + const styles = useMemo(() => makeStyles(theme), [theme]) + + const { data, isLoading } = useClaudeFlags(serverId) + const update = useUpdateClaudeFlags(serverId) + + const [values, setValues] = useState({}) + const [extraArgs, setExtraArgs] = useState('') + + // Seed the edit state from the server's copy by ADJUSTING STATE DURING RENDER + // (https://react.dev/reference/react/useState#storing-information-from-previous-renders) + // rather than in an effect, which would cost a second render pass on every load. + // + // Keyed on the CONTENT, not the object identity: react-query hands back a + // fresh object on every refetch, so an identity key would re-seed mid-edit and + // silently discard whatever the user had just typed. + const serverSnapshot = data ? JSON.stringify([data.values, data.extraArgs]) : null + const [seededFrom, setSeededFrom] = useState(null) + if (serverSnapshot !== null && serverSnapshot !== seededFrom) { + setSeededFrom(serverSnapshot) + setValues(data ? data.values : {}) + setExtraArgs(data?.extraArgs ?? '') + } + + if (isLoading) { + return ( + + + + ) + } + + // null = server predates the feature. Render nothing rather than an error. + if (!data) return null + + const flagLabel = (def: ClaudeFlagDefinition) => + t(`servers:claudeFlags.flags.${def.id}.label`, { defaultValue: def.flag }) + const flagDescription = (def: ClaudeFlagDefinition) => + t(`servers:claudeFlags.flags.${def.id}.description`, { defaultValue: '' }) + + const apply = (def: ClaudeFlagDefinition, next: ClaudeFlagValue | undefined) => { + setValues((prev) => { + const copy = { ...prev } + if (next === undefined) delete copy[def.id] + else copy[def.id] = next + return copy + }) + } + + const stage = (def: ClaudeFlagDefinition, next: ClaudeFlagValue | undefined) => { + // Gate only the transition INTO a dangerous value; clearing one is always + // allowed without friction. Everything else applies synchronously. + if (next !== undefined && claudeFlagValueRisk(def, next) === 'dangerous') { + void confirmDangerousChange(t('servers:claudeFlags.dangerousExplanation')).then((ok) => { + if (ok) apply(def, next) + }) + return + } + apply(def, next) + } + + const onSave = () => { + update.mutate({ values, extraArgs: extraArgs.trim() || undefined }) + } + + return ( + + {t('servers:claudeFlags.title')} + {t('servers:claudeFlags.description')} + + {!data.persisted ? ( + + {t('servers:claudeFlags.notPersisted')} + + ) : null} + + {data.registry.map((def) => { + const value = values[def.id] + const dangerous = value !== undefined && claudeFlagValueRisk(def, value) === 'dangerous' + + return ( + + + + {flagLabel(def)} + + {flagDescription(def) ? ( + {flagDescription(def)} + ) : null} + + + {def.valueType === 'boolean' ? ( + stage(def, on ? true : undefined)} + trackColor={{ false: theme.border, true: theme.text.accent }} + thumbColor="#fff" + testID={`claude-flag-${def.id}`} + /> + ) : def.valueType === 'enum' ? ( + + {(def.enumValues ?? []).map((option) => ( + stage(def, value === option ? undefined : option)} + testID={`claude-flag-${def.id}-${option}`} + > + + {option} + + + ))} + + ) : ( + stage(def, textToValue(def, text))} + autoCapitalize="none" + autoCorrect={false} + placeholder={def.valueType === 'list' ? t('servers:claudeFlags.listHint') : def.flag} + placeholderTextColor={theme.text.secondary} + testID={`claude-flag-${def.id}`} + /> + )} + + ) + })} + + {t('servers:claudeFlags.extraArgsLabel')} + {t('servers:claudeFlags.extraArgsUnsupported')} + + + {update.isError ? ( + {update.error.message} + ) : null} + + + {update.isPending ? ( + + ) : ( + {t('servers:claudeFlags.save')} + )} + + + ) +} + +const makeStyles = (theme: Theme) => + StyleSheet.create({ + section: { marginTop: spacing.lg, gap: spacing.sm }, + sectionTitle: { color: theme.text.primary, fontSize: font.base, fontWeight: '600' }, + sectionDescription: { color: theme.text.secondary, fontSize: font.sm }, + warningBox: { + backgroundColor: theme.bg.secondary, + borderRadius: radius.sm, + padding: spacing.sm, + }, + warningText: { color: theme.text.secondary, fontSize: font.sm }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.border, + }, + rowText: { flex: 1, gap: 2 }, + rowLabel: { color: theme.text.primary, fontSize: font.sm, fontWeight: '500' }, + rowLabelDangerous: { color: theme.text.danger }, + rowDescription: { color: theme.text.secondary, fontSize: font.xs }, + enumRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, maxWidth: '55%' }, + chip: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.sm, + backgroundColor: theme.bg.secondary, + }, + chipActive: { backgroundColor: theme.text.accent }, + chipText: { color: theme.text.secondary, fontSize: font.xs }, + chipTextActive: { color: '#fff' }, + input: { + color: theme.text.primary, + backgroundColor: theme.bg.secondary, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: font.sm, + minWidth: 140, + maxWidth: '55%', + }, + errorText: { color: theme.text.danger, fontSize: font.sm }, + saveBtn: { + backgroundColor: theme.text.accent, + borderRadius: radius.md, + paddingVertical: spacing.sm, + alignItems: 'center', + marginTop: spacing.sm, + }, + saveBtnDisabled: { opacity: 0.6 }, + saveBtnText: { color: '#fff', fontSize: font.sm, fontWeight: '600' }, + }) diff --git a/components/servers/ServerEditModal.tsx b/components/servers/ServerEditModal.tsx index 30d937ac..66cc6b71 100644 --- a/components/servers/ServerEditModal.tsx +++ b/components/servers/ServerEditModal.tsx @@ -12,6 +12,7 @@ import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' import { X, QrCode, XCircle } from 'phosphor-react-native' import { useTranslation } from 'react-i18next' import { PairScannerModal } from '@/components/pair/PairScannerModal' +import { ServerClaudeFlagsSection } from '@/components/servers/ServerClaudeFlagsSection' import { ServerFormFields, splitUrl } from '@/components/servers/ServerFormFields' import { useServersStore } from '@/stores/servers' import { wsManager } from '@/services/ws-client' @@ -198,6 +199,10 @@ export function ServerEditModal({ visible, serverId, onClose }: Props) { > {t('button.save')} + + {/* Only in edit mode: the flags are fetched from the server, which + must already exist (and be reachable) to have any. */} + {isEditMode && serverId ? : null} diff --git a/components/servers/ServerIndexingBanner.tsx b/components/servers/ServerIndexingBanner.tsx index 4ed4bc1c..80949d03 100644 --- a/components/servers/ServerIndexingBanner.tsx +++ b/components/servers/ServerIndexingBanner.tsx @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next' import { type Theme, font, spacing } from '@/constants/theme' import { useTheme } from '@/contexts/ThemeContext' import { useServersStore } from '@/stores/servers' +import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' const BEAM_WIDTH = 72 const TRACK_HEIGHT = 3 @@ -106,24 +107,21 @@ export function ServerIndexingBanner() { const { width: screenWidth } = useWindowDimensions() const servers = useServersStore((s) => s.servers) const displayedServerIds = useServersStore((s) => s.displayedServerIds) - const cacheReady = useServersStore((s) => s.cacheReady) const scanProgress = useServersStore((s) => s.scanProgress) + const fetchStatuses = useServerFetchStatusStore((s) => s.statuses) - const isIndexing = displayedServerIds.some( - (id) => servers[id]?.isConnected && !cacheReady[id], + const warmingServerIds = displayedServerIds.filter( + (id) => fetchStatuses[id]?.status === 'warming_up', ) - if (!isIndexing) return null + if (warmingServerIds.length === 0) return null // Track fills the banner minus horizontal padding. const trackWidth = screenWidth - spacing.lg * 2 - - const progress = displayedServerIds - .filter((id) => servers[id]?.isConnected && !cacheReady[id]) - .map((id) => scanProgress[id]) - .find((p) => p && p.total > 0) - - const fillWidth = progress ? trackWidth * (progress.scanned / progress.total) : 0 + const hasProgress = warmingServerIds.some((id) => { + const progress = scanProgress[id] + return progress && progress.total > 0 + }) return ( {t('indexing.label')} - {progress ? ( - - {t('indexing.progress', { - scanned: progress.scanned.toLocaleString(), - total: progress.total.toLocaleString(), - })} - - ) : ( - {t('indexing.subtitle')} - )} + {!hasProgress && {t('indexing.subtitle')}} {/* Pulse dots */} @@ -153,13 +142,42 @@ export function ServerIndexingBanner() { ))} - {/* Scan-beam track: determinate fill when progress known, indeterminate otherwise */} - - {progress ? ( - - ) : ( - - )} + + {warmingServerIds.map((serverId) => { + const progress = scanProgress[serverId] + const validProgress = progress && progress.total > 0 ? progress : null + const fillWidth = validProgress + ? trackWidth * (validProgress.scanned / validProgress.total) + : 0 + const serverLabel = servers[serverId]?.label || servers[serverId]?.url || serverId + + return ( + + + + {serverLabel} + + {validProgress && ( + + {t('indexing.progress', { + scanned: validProgress.scanned.toLocaleString(), + total: validProgress.total.toLocaleString(), + })} + + )} + + + {/* Determinate fill when progress is known, indeterminate beam otherwise. */} + + {validProgress ? ( + + ) : ( + + )} + + + ) + })} ) @@ -189,6 +207,24 @@ function makeStyles(theme: Theme) { fontSize: font.xs, lineHeight: 16, }, + serverList: { + gap: spacing.sm, + }, + serverProgress: { + gap: 2, + }, + serverHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + }, + serverLabel: { + flex: 1, + color: theme.text.primary, + fontSize: font.sm, + fontWeight: '600', + }, dotsRow: { flexDirection: 'row', gap: DOT_SPACING - DOT_SIZE, diff --git a/components/servers/ServerListCard.tsx b/components/servers/ServerListCard.tsx index eed9d71b..36fdad1a 100644 --- a/components/servers/ServerListCard.tsx +++ b/components/servers/ServerListCard.tsx @@ -64,7 +64,7 @@ export function ServerListCard({ server, isRefreshing, onRemove, onEdit, onRefre const handleRemove = () => { Alert.alert( i18n.t('servers:dialog.removeTitle'), - `Disconnect from ${server.label || server.url}?`, + i18n.t('servers:dialog.removeMessage', { server: server.label || server.url }), [ { text: i18n.t('common:button.cancel'), style: 'cancel' }, { text: i18n.t('servers:dialog.removeConfirm'), style: 'destructive', onPress: () => onRemove(server.id) }, diff --git a/components/servers/ServerStateMessage.tsx b/components/servers/ServerStateMessage.tsx index ffe71960..63e64fcd 100644 --- a/components/servers/ServerStateMessage.tsx +++ b/components/servers/ServerStateMessage.tsx @@ -61,7 +61,7 @@ export function ServerStateMessage({ activeServerIds, servers, fetchStatuses, ws const wsStatus = wsManager.status(id) const fetchStatus = fetchStatuses[id]?.status ?? 'ok' const fetchOk = fetchStatus === 'ok' - if (fetchStatus === 'indexing') indexing.push(id) + if (fetchStatus === 'warming_up') indexing.push(id) else if (wsStatus === 'connected' && fetchOk) healthy.push(id) else if (wsStatus === 'disconnected' && !fetchOk) unreachable.push(id) else if (wsStatus === 'connected' && !fetchOk) fetchFailed.push(id) diff --git a/components/servers/ServersStatusModal.tsx b/components/servers/ServersStatusModal.tsx index 1243e72e..06923ce5 100644 --- a/components/servers/ServersStatusModal.tsx +++ b/components/servers/ServersStatusModal.tsx @@ -246,7 +246,7 @@ export function ServersStatusModal({ visible, onClose }: Props) { const server = servers[serverId] Alert.alert( i18n.t('servers:dialog.removeTitle'), - `Disconnect from ${server?.label || server?.url}?`, + i18n.t('servers:dialog.removeMessage', { server: server?.label || server?.url }), [ { text: i18n.t('common:button.cancel'), style: 'cancel' }, { diff --git a/components/sessions/SessionCard.tsx b/components/sessions/SessionCard.tsx index c20c041b..e5fe553f 100644 --- a/components/sessions/SessionCard.tsx +++ b/components/sessions/SessionCard.tsx @@ -14,11 +14,14 @@ import { useTheme, useIsGlass } from '@/contexts/ThemeContext' import { GlassFill } from '@/components/ui/GlassFill' import { FolderSimple } from 'phosphor-react-native' import type { MultiSession } from '@/types/api' +import { conversationHref } from '@/lib/conversationHref' +import { isExternalSession, isExternalAlive } from '@/lib/externalSession' import { useSessionActions } from '@/hooks/useSessionActions' import { useServersStore } from '@/stores/servers' import { useSessionNamesStore } from '@/stores/sessionNames' import { useTranslation } from 'react-i18next' import i18n from '@/lib/i18n' +import { useReducedMotion } from '@/hooks/useReducedMotion' // Track which session IDs have already played their enter animation so // polling-driven remounts don't re-trigger FadeInDown. @@ -49,27 +52,45 @@ export function SessionCard({ session, isFirstSession = false }: Props) { const serverColor = useServersStore((s) => s.servers[session.serverId]?.color) ?? SERVER_COLOR_DEFAULT const customName = useSessionNamesStore((s) => s.getName(session.serverId, session.id)) const displayName = customName ?? session.projectName + const reduceMotion = useReducedMotion() const compoundId = `${session.serverId}::${session.id}` const isNew = !_animatedIds.has(compoundId) if (isNew) _animatedIds.add(compoundId) const isLive = session.status === 'running' || session.status === 'waiting_input' - // Brand thread spine: amber for live (running / waiting_input), then the - // server's assigned identity color when multi-server (so you can see at a - // glance which server the card came from), then brand blue for idle. - // Echoes the brand mark; not a decorative side-stripe border. + // A discovered process the streamer only observes — read-only, not + // interactive. Routing keys on `ownership` (strict); the alive indicator keys + // on the liveness fields with a pid fallback for older servers, so it does + // not require `ownership` to be present. + const isExternal = isExternalSession(session) + const externalAlive = isExternalAlive(session) + // Brand thread spine: amber for live (running / waiting_input), blue for an + // alive external (observed) session, then the server's assigned identity + // color when multi-server (so you can see at a glance which server the card + // came from), then brand blue for idle. Echoes the brand mark; not a + // decorative side-stripe border. const spineColor = isLive ? theme.status.waiting - : multipleServers - ? serverColor - : theme.text.accent + : externalAlive + ? theme.status.completed + : multipleServers + ? serverColor + : theme.text.accent const handlePress = useCallback(() => { Haptics.selectionAsync() + if (isExternal) { + const convId = session.boundConversationId ?? session.conversationId ?? session.id + router.push(conversationHref(convId, session.serverId)) + return + } router.push(`/session/${session.id}?server=${session.serverId}`) - }, [session.id, session.serverId, router]) + }, [session, isExternal, router]) const handleLongPress = useCallback(() => { + // External sessions are read-only — suppress the input-oriented actions + // (Send Input / Cancel) entirely so they can never be triggered. + if (isExternal) return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) const options = [ i18n.t('sessions:card.copyId'), @@ -106,7 +127,7 @@ export function SessionCard({ session, isFirstSession = false }: Props) { { text: 'Dismiss', style: 'cancel' }, ]) } - }, [session, cancelSession, router]) + }, [session, isExternal, cancelSession, router]) const elapsedLabel = formatElapsed(session.elapsedMs) const promptsLabel = t('card.prompts', { count: session.promptCount }) @@ -114,7 +135,7 @@ export function SessionCard({ session, isFirstSession = false }: Props) { const timeLabel = lastActivityTs ? formatListTime(lastActivityTs) : null return ( - + {/* Thread spine — structural column, brand-mark echo. */} - + {/* Line 1: project name + trailing meta chips */} @@ -151,7 +172,7 @@ export function SessionCard({ session, isFirstSession = false }: Props) { {/* Line 2: status + runtime + prompts in mono. The bullets give the row a terminal-log rhythm without adding chrome. */} - + {elapsedLabel} diff --git a/components/sessions/SessionStatusBadge.tsx b/components/sessions/SessionStatusBadge.tsx index d86469d3..a36990fe 100644 --- a/components/sessions/SessionStatusBadge.tsx +++ b/components/sessions/SessionStatusBadge.tsx @@ -1,39 +1,68 @@ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native' +import { useTranslation } from 'react-i18next' import { font, spacing, type Theme } from '@/constants/theme' import { useTheme } from '@/contexts/ThemeContext' import { LiveDot } from './LiveDot' import type { SessionStatus } from '@/types/api' - -const STATUS_LABELS: Record = { - running: 'Running', - waiting_input: 'Active', - idle: 'Idle', -} +import { + deriveSessionPresentation, + type SessionColorToken, + type SessionPresentationInput, +} from '@/lib/sessionPresentation' interface Props { status: SessionStatus isRefetching?: boolean + /** + * When true, render the distinct "external — alive" treatment. Prefer + * passing `session` so kind/label come from `deriveSessionPresentation`. + */ + externalAlive?: boolean + /** When set, badge kind/label/color come from the shared presentation helper. */ + session?: SessionPresentationInput +} + +function colorForToken(theme: Theme, token: SessionColorToken): string { + switch (token) { + case 'running': + return theme.status.running + case 'waiting': + return theme.status.waiting + case 'completed': + return theme.status.completed + case 'failed': + return theme.status.failed + case 'idle': + default: + return theme.status.idle + } } -export function SessionStatusBadge({ status, isRefetching }: Props) { +export function SessionStatusBadge({ status, isRefetching, externalAlive, session }: Props) { const theme = useTheme() + const { t } = useTranslation('sessions') const styles = makeStyles(theme) - const STATUS_COLORS: Record = { - running: theme.status.running, - waiting_input: theme.status.running, - idle: theme.status.idle, - } - const color = STATUS_COLORS[status] ?? theme.status.idle - const isLive = status === 'running' || status === 'waiting_input' + + const presentation = session + ? deriveSessionPresentation(session) + : deriveSessionPresentation({ + status: externalAlive ? 'idle' : status, + ownership: externalAlive ? 'external' : 'managed', + processLiveness: externalAlive ? 'alive' : undefined, + ptyAttached: !externalAlive && (status === 'running' || status === 'waiting_input'), + }) + + const color = colorForToken(theme, presentation.colorToken) + const label = t(presentation.labelKey) return ( - + {isRefetching ? ( ) : ( - + )} - {STATUS_LABELS[status]} + {label} ) } diff --git a/components/sessions/hub/ConvRow.tsx b/components/sessions/hub/ConvRow.tsx index 27d20f45..20caed15 100644 --- a/components/sessions/hub/ConvRow.tsx +++ b/components/sessions/hub/ConvRow.tsx @@ -6,12 +6,11 @@ import { useSettingsStore } from '@/stores/settings' import { ConversationListItem } from '@/components/sessions/shared/ConversationListItem' import type { ConvRowProps } from './types' -export function ConvRow({ conv, onLongPress }: ConvRowProps) { +export function ConvRow({ conv, onLongPress, forceServerChip = false }: ConvRowProps) { const router = useRouter() const activeServerCount = useServersStore((s) => s.activeServerIds.length) const serverColor = useServersStore((s) => s.servers[conv.serverId]?.color) const previewPref = useSettingsStore((s) => s.historyMessageDisplay) - console.log(`[ConvRow] id=${conv.id} provider=${conv.provider ?? 'undefined'}`) const handlePress = useCallback(() => { Haptics.selectionAsync() @@ -35,6 +34,7 @@ export function ConvRow({ conv, onLongPress }: ConvRowProps) { serverLabel={conv.serverLabel} serverColor={serverColor} activeServerCount={activeServerCount} + forceServerChip={forceServerChip} previewMode={previewPref === 'last' ? 'last' : 'first'} density="compact" leading="dot" diff --git a/components/sessions/hub/ProjectHubCard.tsx b/components/sessions/hub/ProjectHubCard.tsx index 23c7537f..081f132e 100644 --- a/components/sessions/hub/ProjectHubCard.tsx +++ b/components/sessions/hub/ProjectHubCard.tsx @@ -21,7 +21,7 @@ if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental?.(true) } -export function ProjectHubCard({ group, isOpen, onToggle }: ProjectHubCardProps) { +export function ProjectHubCard({ group, isOpen, onToggle, forceServerChip = false }: ProjectHubCardProps) { const { t } = useTranslation('sessions') const theme = useTheme() const styles = makeStyles(theme) @@ -141,12 +141,12 @@ export function ProjectHubCard({ group, isOpen, onToggle }: ProjectHubCardProps) ...group.sessions.map((s) => ({ key: `s-${s.serverId}::${s.id}`, ms: s.completedAt ? Date.parse(s.completedAt) : Date.parse(s.startedAt) + (s.elapsedMs ?? 0), - node: , + node: , })), ...group.conversations.map((c) => ({ key: `c-${c.serverId}::${c.id}`, ms: Date.parse(c.lastActivity) || 0, - node: , + node: , })), ] .sort((a, b) => b.ms - a.ms) @@ -161,6 +161,7 @@ export function ProjectHubCard({ group, isOpen, onToggle }: ProjectHubCardProps) ))} @@ -174,6 +175,7 @@ export function ProjectHubCard({ group, isOpen, onToggle }: ProjectHubCardProps) key={`${conv.serverId}::${conv.id}`} conv={conv} onLongPress={setActiveConv} + forceServerChip={forceServerChip} /> ))} {convCount > 5 && ( diff --git a/components/sessions/hub/ProjectHubList.styles.ts b/components/sessions/hub/ProjectHubList.styles.ts index 5ee019ae..163a6379 100644 --- a/components/sessions/hub/ProjectHubList.styles.ts +++ b/components/sessions/hub/ProjectHubList.styles.ts @@ -38,6 +38,10 @@ export function makeStyles(theme: Theme, bottomInset: number = 0) { emptyListContent: { flexGrow: 1, }, + serverEmpty: { + paddingHorizontal: spacing.sm, + paddingBottom: spacing.md, + }, sectionHeader: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg, diff --git a/components/sessions/hub/ProjectHubList.tsx b/components/sessions/hub/ProjectHubList.tsx index fbc72dd9..35830e17 100644 --- a/components/sessions/hub/ProjectHubList.tsx +++ b/components/sessions/hub/ProjectHubList.tsx @@ -21,6 +21,12 @@ import type { MultiSession, MultiConversation } from '@/types/api' import { QuickAccessActionSheet } from '@/components/quick-access/QuickAccessActionSheet' import { useQuickAccessStore, buildFavoriteId } from '@/stores/quickAccess' import { conversationHref } from '@/lib/conversationHref' +import { isExternalSession, isExternalAlive } from '@/lib/externalSession' +import { + collidingProjectPaths, + shouldForceServerChip, +} from '@/lib/projectDisambiguation' +import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' export function ProjectHubList({ sessions, @@ -50,12 +56,17 @@ export function ProjectHubList({ const activeServerIds = useServersStore((s) => s.activeServerIds) const servers = useServersStore((s) => s.servers) + const fetchStatuses = useServerFetchStatusStore((s) => s.statuses) const serverLabels = useMemo( () => Object.fromEntries(activeServerIds.map((id) => [id, servers[id]?.label ?? id])), [activeServerIds, servers], ) const serverGroups = useServerGroups(groups, activeServerIds, serverLabels) const showServerHeaders = serverGroups.length > 0 + const collidingPaths = useMemo( + () => collidingProjectPaths([...sessions, ...conversations]), + [sessions, conversations], + ) const [collapsedServers, setCollapsedServers] = useState>(new Set()) const toggleServer = useCallback((serverId: string) => { setCollapsedServers((prev) => { @@ -88,6 +99,13 @@ export function ProjectHubList({ const handleSessionPress = useCallback( (item: MultiSession) => { + // External sessions are read-only — route to the conversation view, never + // the PTY screen (which exposes the destructive Overtake / input paths). + if (isExternalSession(item)) { + const convId = item.boundConversationId ?? item.conversationId ?? item.id + router.push(conversationHref(convId, item.serverId)) + return + } router.push(`/session/${item.id}?server=${item.serverId}`) }, [router], @@ -133,8 +151,10 @@ export function ProjectHubList({ ({ item }: { item: MultiConversation | MultiSession }) => { const isSession = isMultiSession(item) const serverColor = item.serverId ? servers[item.serverId]?.color : undefined + const forceServerChip = shouldForceServerChip(item.projectPath, collidingPaths) if (isSession) { - const isLive = item.status === 'running' || item.status === 'waiting_input' + const externalAlive = isExternalAlive(item) + const isLive = externalAlive || item.status === 'running' || item.status === 'waiting_input' return ( ) }, - [handleConversationPress, handleSessionPress, servers, activeServerCount, debouncedQuery], + [ + handleConversationPress, + handleSessionPress, + servers, + activeServerCount, + debouncedQuery, + collidingPaths, + ], ) const renderSectionHeader = useCallback( @@ -195,6 +225,7 @@ export function ProjectHubList({ type HubFlatItem = | { kind: 'header'; serverId: string; serverLabel: string; totalCount: number } | { kind: 'group'; group: ProjectGroup } + | { kind: 'serverEmpty'; serverId: string } const hubFlatData = useMemo((): HubFlatItem[] => { // Collapse only applies with more than one visible server; with a single @@ -204,9 +235,13 @@ export function ProjectHubList({ return showServerHeaders ? serverGroups.flatMap((sg) => { const expanded = !collapseApplies || !collapsedServers.has(sg.serverId) + const body: HubFlatItem[] = + sg.totalCount > 0 + ? sg.groups.map((g) => ({ kind: 'group' as const, group: g })) + : [{ kind: 'serverEmpty' as const, serverId: sg.serverId }] return [ { kind: 'header' as const, serverId: sg.serverId, serverLabel: sg.serverLabel, totalCount: sg.totalCount }, - ...(expanded ? sg.groups.map((g) => ({ kind: 'group' as const, group: g })) : []), + ...(expanded ? body : []), ] }) : groups.map((g) => ({ kind: 'group' as const, group: g })) @@ -261,9 +296,11 @@ export function ProjectHubList({ ) : ( - item.kind === 'header' ? `header-${item.serverId}` : `project:${item.group.projectId}` - } + keyExtractor={(item) => { + if (item.kind === 'header') return `header-${item.serverId}` + if (item.kind === 'serverEmpty') return `empty-${item.serverId}` + return `project:${item.group.projectId}` + }} renderItem={({ item }) => { if (item.kind === 'header') { return ( @@ -278,11 +315,33 @@ export function ProjectHubList({ /> ) } + if (item.kind === 'serverEmpty') { + const fetchStatus = fetchStatuses[item.serverId]?.status + const isError = fetchStatus === 'error' + // Third status is the warm-up / indexing state — treat non-ok/non-error as warming. + const isWarming = fetchStatus != null && fetchStatus !== 'ok' && fetchStatus !== 'error' + const emptyTitle = isError + ? t('list.serverOffline') + : isWarming + ? t('list.serverWarming') + : t('list.serverEmpty') + const emptySubtitle = isError + ? t('list.serverOfflineSubtitle') + : isWarming + ? t('list.serverWarmingSubtitle') + : t('list.serverEmptySubtitle') + return ( + + + + ) + } return ( toggleOpen(item.group.projectId)} + forceServerChip={shouldForceServerChip(item.group.projectPath, collidingPaths)} /> ) }} diff --git a/components/sessions/hub/SessionRow.tsx b/components/sessions/hub/SessionRow.tsx index 23a8c708..750d7650 100644 --- a/components/sessions/hub/SessionRow.tsx +++ b/components/sessions/hub/SessionRow.tsx @@ -7,23 +7,37 @@ import { useServersStore } from '@/stores/servers' import { useSessionNamesStore } from '@/stores/sessionNames' import { useSettingsStore } from '@/stores/settings' import { ConversationListItem } from '@/components/sessions/shared/ConversationListItem' +import { conversationHref } from '@/lib/conversationHref' +import { isExternalSession } from '@/lib/externalSession' +import { deriveSessionPresentation } from '@/lib/sessionPresentation' import { formatElapsed } from './hubUtils' import type { MessagePreviewMode } from '@/components/sessions/shared/MessagePreview' import type { SessionRowProps } from './types' -export function SessionRow({ session }: SessionRowProps) { +export function SessionRow({ session, forceServerChip = false }: SessionRowProps) { const router = useRouter() const { cancelSession } = useSessionActions(session.serverId, session.id) const activeServerCount = useServersStore((s) => s.activeServerIds.length) const serverColor = useServersStore((s) => s.servers[session.serverId]?.color) - const sessionName = useSessionNamesStore((s) => s.getName(session.serverId, session.id)) + // User rename wins; then the JSONL-derived conversation name off the session. + const renamedName = useSessionNamesStore((s) => s.getName(session.serverId, session.id)) + const sessionName = renamedName ?? session.sessionName + + const presentation = deriveSessionPresentation(session) + const isExternal = isExternalSession(session) const handlePress = useCallback(() => { Haptics.selectionAsync() + if (presentation.capabilities.isObserveOnly || isExternal) { + const convId = session.boundConversationId ?? session.conversationId ?? session.id + router.push(conversationHref(convId, session.serverId)) + return + } router.push(`/session/${session.id}?server=${session.serverId}`) - }, [session.id, session.serverId, router]) + }, [session, presentation.capabilities.isObserveOnly, isExternal, router]) const handleLongPress = useCallback(() => { + if (!presentation.capabilities.canCancel) return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) if (Platform.OS === 'ios') { ActionSheetIOS.showActionSheetWithOptions( @@ -50,28 +64,31 @@ export function SessionRow({ session }: SessionRowProps) { { text: 'Dismiss', style: 'cancel' }, ]) } - }, [session, cancelSession]) + }, [session, presentation.capabilities.canCancel, cancelSession]) const rowPreviewModeSetting = useSettingsStore((s) => s.rowPreviewMode) const previewMode: MessagePreviewMode = rowPreviewModeSetting === 'off' ? 'none' : rowPreviewModeSetting - const isLive = session.status === 'running' || session.status === 'waiting_input' const branchAndElapsed = [session.branch || 'no git', formatElapsed(session.elapsedMs)].join(' · ') const titleSuffix = sessionName?.trim() || branchAndElapsed const promptCountLabel = `${session.promptCount} prompt${session.promptCount === 1 ? '' : 's'}` + const activityTimestamp = presentation.activityAt ?? session.completedAt ?? session.startedAt return ( void + forceServerChip?: boolean } export interface SessionRowProps { session: MultiSession + forceServerChip?: boolean } export interface ConvRowProps { conv: MultiConversation onLongPress?: (conv: MultiConversation) => void + forceServerChip?: boolean } export function isMultiSession(item: MultiConversation | MultiSession): item is MultiSession { diff --git a/components/sessions/hub/useServerGroups.ts b/components/sessions/hub/useServerGroups.ts index 8ef62c14..1684bc98 100644 --- a/components/sessions/hub/useServerGroups.ts +++ b/components/sessions/hub/useServerGroups.ts @@ -22,21 +22,16 @@ export function useServerGroups( return useMemo(() => { if (activeServerIds.length <= 1) return [] + // Always seed every active server so offline / empty hosts still get a + // section header instead of vanishing from the hub. const map = new Map() - - for (const group of projectGroups) { - const allItems = [...group.sessions, ...group.conversations] - for (const item of allItems) { - const serverId = item.serverId - if (!map.has(serverId)) { - map.set(serverId, { - serverId, - serverLabel: serverLabels[serverId] ?? serverId, - groups: [], - totalCount: 0, - }) - } - } + for (const serverId of activeServerIds) { + map.set(serverId, { + serverId, + serverLabel: serverLabels[serverId] ?? serverId, + groups: [], + totalCount: 0, + }) } for (const group of projectGroups) { @@ -46,31 +41,30 @@ export function useServerGroups( ]) for (const serverId of serverIds) { const serverGroup = map.get(serverId) - if (serverGroup) { - const filteredGroup: ProjectGroup = { - ...group, - sessions: group.sessions.filter((s) => s.serverId === serverId), - conversations: group.conversations.filter((c) => c.serverId === serverId), - } - filteredGroup.latestActivityMs = Math.max( - ...filteredGroup.sessions.map((s) => - s.completedAt ? toMs(s.completedAt) : toMs(s.startedAt) + (s.elapsedMs ?? 0), - ), - ...filteredGroup.conversations.map((c) => toMs(c.lastActivity)), - 0, - ) - filteredGroup.earliestStartMs = filteredGroup.sessions.length > 0 - ? Math.min(...filteredGroup.sessions.map((s) => toMs(s.startedAt)).filter((ms) => ms > 0)) - : 0 - serverGroup.groups.push(filteredGroup) - serverGroup.totalCount += - filteredGroup.sessions.length + filteredGroup.conversations.length + if (!serverGroup) continue + const filteredGroup: ProjectGroup = { + ...group, + sessions: group.sessions.filter((s) => s.serverId === serverId), + conversations: group.conversations.filter((c) => c.serverId === serverId), } + filteredGroup.latestActivityMs = Math.max( + ...filteredGroup.sessions.map((s) => + s.completedAt ? toMs(s.completedAt) : toMs(s.startedAt) + (s.elapsedMs ?? 0), + ), + ...filteredGroup.conversations.map((c) => toMs(c.lastActivity)), + 0, + ) + filteredGroup.earliestStartMs = filteredGroup.sessions.length > 0 + ? Math.min(...filteredGroup.sessions.map((s) => toMs(s.startedAt)).filter((ms) => ms > 0)) + : 0 + serverGroup.groups.push(filteredGroup) + serverGroup.totalCount += + filteredGroup.sessions.length + filteredGroup.conversations.length } } return activeServerIds .map((id) => map.get(id)) - .filter((sg): sg is ServerGroup => sg !== undefined && sg.totalCount > 0) + .filter((sg): sg is ServerGroup => sg !== undefined) }, [projectGroups, activeServerIds, serverLabels]) } diff --git a/components/sessions/shared/ConversationListItem.tsx b/components/sessions/shared/ConversationListItem.tsx index 3d2b0ccd..f3275346 100644 --- a/components/sessions/shared/ConversationListItem.tsx +++ b/components/sessions/shared/ConversationListItem.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react' import { View, Text, Pressable, StyleSheet } from 'react-native' +import { useTranslation } from 'react-i18next' import { HighlightText } from 'one-more-highlight/native' import { brand, font, spacing, type Theme } from '@/constants/theme' import { useTheme } from '@/contexts/ThemeContext' @@ -29,6 +30,13 @@ export interface ConversationListItemProps { branch?: string | null /** When set, the row gains a pulsing amber live indicator + LIVE pill. */ live?: boolean + /** + * When set (with `live`), renders the read-only "external / observed" variant + * instead of the interactive amber treatment: a blue dot + EXTERNAL pill. + * Distinguishes a discovered process the streamer only observes from a + * streamer-owned live session. + */ + external?: boolean /** Optional message snapshots used by `MessagePreview`. */ firstMessage?: { text: string } | null @@ -59,6 +67,11 @@ export interface ConversationListItemProps { serverChipVariant?: ServerChipVariant /** Total active servers in the app — used to resolve `showServer === 'auto'`. */ activeServerCount?: number + /** + * Force the server chip even when `showServer === 'auto'` would hide it — + * used when the same project path exists on multiple servers. + */ + forceServerChip?: boolean /** Search-result inline substring highlight. */ highlight?: string @@ -81,15 +94,21 @@ export interface ConversationListItemProps { const STRIP_WIDTH = 3 const STRIP_RADIUS = 2 -function shouldShowServer(mode: ConversationListServerMode, activeServerCount: number | undefined, hasLabel: boolean): boolean { +function shouldShowServer( + mode: ConversationListServerMode, + activeServerCount: number | undefined, + hasLabel: boolean, + forceServerChip: boolean, +): boolean { if (!hasLabel) return false if (mode === 'never') return false - if (mode === 'always') return true + if (mode === 'always' || forceServerChip) return true return (activeServerCount ?? 0) > 1 } export function ConversationListItem(props: ConversationListItemProps) { const theme = useTheme() + const { t } = useTranslation('sessions') const styles = makeStyles(theme) const { title, @@ -99,6 +118,7 @@ export function ConversationListItem(props: ConversationListItemProps) { messageCount, branch, live = false, + external = false, firstMessage, lastMessage, preview, @@ -115,6 +135,7 @@ export function ConversationListItem(props: ConversationListItemProps) { showServer = 'auto', serverChipVariant = 'label', activeServerCount, + forceServerChip = false, highlight, showCount = true, showBranch = true, @@ -126,10 +147,18 @@ export function ConversationListItem(props: ConversationListItemProps) { testID, } = props - const serverVisible = shouldShowServer(showServer, activeServerCount, Boolean(serverLabel)) + // Blue for an observed external session, amber for an interactive live one. + const liveColor = external ? theme.status.completed : theme.status.waiting + const livePillLabel = external ? t('status.externalPill') : t('status.livePill') + const serverVisible = shouldShowServer( + showServer, + activeServerCount, + Boolean(serverLabel), + forceServerChip, + ) const stripColor = serverVisible ? (serverColor ?? SERVER_COLOR_DEFAULT) - : (live ? theme.status.waiting : null) + : (live ? liveColor : null) // Path rendering — only consulted when no title. const pathParts = useMemo(() => { @@ -215,7 +244,7 @@ export function ConversationListItem(props: ConversationListItemProps) { )} {leading === 'dot' && !isChip && ( - + )} {leading === 'depth' && !isChip && ( @@ -252,9 +281,11 @@ export function ConversationListItem(props: ConversationListItemProps) { {!isChip ? ( {live ? ( - - - LIVE + + + + {livePillLabel} + ) : timeText ? ( ) doesn't re-render when // only the position changes — which happens on every WS frame. const LineGutter = memo(function LineGutter({ index }: { index: number }) { - return {index + 1} + return ( + + {index + 1} + + ) }) -const LineText = memo(function LineText({ line, userMessageTexts }: { line: string; userMessageTexts?: Set }) { +const LineText = memo(function LineText({ + line, + userMessageTexts, + accessibilityLabel, +}: { + line: string + userMessageTexts?: Set + accessibilityLabel?: string +}) { const clean = stripAnsi(line) - // '❯ ' transcript lines are the user's submitted messages — style - // them so they stand out from agent output. const userOwned = isUserLine(clean, userMessageTexts) return ( - + {clean} ) }) -// Outer wrapper stays cheap (only `index` changes); LineText memoises on `line`. const LineRow = memo(function LineRow({ line, index, userMessageTexts }: LineRowProps) { + const clean = stripAnsi(line) + const a11yLabel = i18n.t('terminal:a11y.line', { n: index + 1, text: clean }) return ( - - - - + + + + + + ) }) @@ -133,6 +160,12 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe setShowJumpButton(0) }, [scrollToBottom]) + const copyAll = useCallback(async () => { + const text = lines.map((l) => stripAnsi(l)).join('\n') + if (!text.trim()) return + await Clipboard.setStringAsync(text) + }, [lines]) + const renderItem = useCallback(({ item, index }: { item: TerminalLine; index: number }) => { return }, [userMessageTexts]) @@ -221,6 +254,15 @@ export function TerminalOutput({ lines, isStreaming: _isStreaming, userMessageTe + + + + void } -export function TerminalView({ serverId, sessionId, disabled = false, pendingPlan = null, onClosePlan }: Props) { - const { lines, isStreaming, userMessageTexts } = useTerminalStream(serverId, sessionId) +export function TerminalView({ + serverId, + sessionId, + provider, + parseConfidence: parseConfidenceProp, + disabled = false, + pendingPlan = null, + onClosePlan, +}: Props) { + const { t } = useTranslation('terminal') + const { lines, isStreaming, userMessageTexts, parseConfidence } = useTerminalStream( + serverId, + sessionId, + false, + provider, + ) + const confidence = parseConfidenceProp ?? parseConfidence const { sendInput, sendKeys, respondToQuestion } = useSessionActions(serverId, sessionId) const { question: activeQuestion } = useActiveQuestion(serverId, sessionId) const onSend = (payload: string) => { + markSessionUsed(sessionId) sendInput.mutate(payload, { onError: (err) => Alert.alert('Send failed', err instanceof Error ? err.message : String(err)), }) @@ -54,6 +76,11 @@ export function TerminalView({ serverId, sessionId, disabled = false, pendingPla return ( + {confidence === 'low' ? ( + + {t('session.rawModeNote')} + + ) : null} ) } + +const styles = StyleSheet.create({ + rawNote: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#21262d', + borderBottomWidth: 1, + borderBottomColor: '#30363d', + }, + rawNoteText: { + color: '#d29922', + fontSize: 11, + lineHeight: 15, + }, +}) diff --git a/constants/a11y.ts b/constants/a11y.ts new file mode 100644 index 00000000..9a3d4466 --- /dev/null +++ b/constants/a11y.ts @@ -0,0 +1,8 @@ +/** Shared accessibility / Dynamic Type constants for dense surfaces. */ +export const MIN_TOUCH_TARGET = 44 + +/** Cap Dynamic Type on monospace terminal/diff so layout stays usable. */ +export const MAX_FONT_SIZE_MULTIPLIER_MONO = 1.35 + +/** Primary UI text can scale further while remaining readable. */ +export const MAX_FONT_SIZE_MULTIPLIER_UI = 2 diff --git a/crash-log.txt b/crash-log.txt deleted file mode 100644 index ce24b367..00000000 --- a/crash-log.txt +++ /dev/null @@ -1,621 +0,0 @@ -------------------------------------- -Translated Report (Full Report Below) -------------------------------------- -Process: SpringBoard [9731] -Path: /Volumes/VOLUME/*/SpringBoard.app/SpringBoard -Identifier: com.apple.springboard -Version: 1.0 (50) -Code Type: ARM-64 (Native) -Role: Foreground -Parent Process: launchd_sim [76361] -Coalition: com.apple.CoreSimulator.SimDevice.20173D7E-12AD-40DC-AA5E-54294B49121F [11695] -Responsible Process: SimulatorTrampoline [1517] -User ID: 501 - -Date/Time: 2026-06-09 19:07:52.1237 +0300 -Launch Time: 2026-06-09 17:33:55.7948 +0300 -Hardware Model: Mac14,9 -OS Version: macOS 26.5 (25F71) -Release Type: User - -Crash Reporter Key: D8E09C9E-2577-6762-A15F-71FB31FAEC8C -Incident Identifier: 227F1D5E-A20F-4E31-94AA-6F555DF0050E - -Sleep/Wake UUID: C8733ABD-CE82-46DF-BB98-D1BE6663BD16 - -Time Awake Since Boot: 160000 seconds -Time Since Wake: 117338 seconds - -System Integrity Protection: enabled - -Triggered by Thread: 9, Dispatch Queue: com.apple.root.user-initiated-qos - -Exception Type: EXC_BAD_ACCESS (SIGSEGV) -Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000020 -Exception Codes: 0x0000000000000001, 0x0000000000000020 - -Termination Reason: Namespace SIGNAL, Code 11, Segmentation fault: 11 -Terminating Process: exc handler [9731] - - -VM Region Info: 0x20 is not in any region. Bytes before following region: 4340449248 - REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL - UNUSED SPACE AT START ----> - __TEXT 102b60000-102b78000 [ 96K] r-x/r-x SM=COW /Volumes/VOLUME/*/SpringBoard.app/SpringBoard - -Thread 0:: Dispatch queue: com.apple.main-thread -0 libsystem_kernel.dylib 0x103038b70 mach_msg2_trap + 8 -1 libsystem_kernel.dylib 0x103049e5c mach_msg2_internal + 72 -2 libsystem_kernel.dylib 0x103040c44 mach_msg_overwrite + 484 -3 libsystem_kernel.dylib 0x103038ef0 mach_msg + 20 -4 CoreFoundation 0x180455c04 __CFRunLoopServiceMachPort + 156 -5 CoreFoundation 0x180454dbc __CFRunLoopRun + 1128 -6 CoreFoundation 0x18044fcec _CFRunLoopRunSpecificWithOptions + 496 -7 GraphicsServices 0x192a669bc GSEventRunModal + 116 -8 UIKitCore 0x186348574 -[UIApplication _run] + 772 -9 UIKitCore 0x18634c79c UIApplicationMain + 124 -10 SpringBoard 0x105209030 SBSystemAppMain + 4848 -11 dyld_sim 0x102ca53d0 start_sim + 20 -12 dyld 0x102f17e00 start + 6992 - -Thread 1:: com.apple.uikit.eventfetch-thread -0 libsystem_kernel.dylib 0x103038b70 mach_msg2_trap + 8 -1 libsystem_kernel.dylib 0x103049e5c mach_msg2_internal + 72 -2 libsystem_kernel.dylib 0x103040c44 mach_msg_overwrite + 484 -3 libsystem_kernel.dylib 0x103038ef0 mach_msg + 20 -4 CoreFoundation 0x180455c04 __CFRunLoopServiceMachPort + 156 -5 CoreFoundation 0x180454dbc __CFRunLoopRun + 1128 -6 CoreFoundation 0x18044fcec _CFRunLoopRunSpecificWithOptions + 496 -7 Foundation 0x18110be48 -[NSRunLoop(NSRunLoop) runMode:beforeDate:] + 208 -8 Foundation 0x18110c068 -[NSRunLoop(NSRunLoop) runUntilDate:] + 60 -9 UIKitCore 0x18609fc50 -[UIEventFetcher threadMain] + 392 -10 Foundation 0x181132d14 __NSThread__start__ + 716 -11 libsystem_pthread.dylib 0x102de263c _pthread_start + 104 -12 libsystem_pthread.dylib 0x102ddda34 thread_start + 8 - -Thread 2:: com.apple.CoreMotion.MotionThread -0 libsystem_kernel.dylib 0x103038b70 mach_msg2_trap + 8 -1 libsystem_kernel.dylib 0x103049e5c mach_msg2_internal + 72 -2 libsystem_kernel.dylib 0x103040c44 mach_msg_overwrite + 484 -3 libsystem_kernel.dylib 0x103038ef0 mach_msg + 20 -4 CoreFoundation 0x180455c04 __CFRunLoopServiceMachPort + 156 -5 CoreFoundation 0x180454dbc __CFRunLoopRun + 1128 -6 CoreFoundation 0x18044fcec _CFRunLoopRunSpecificWithOptions + 496 -7 CoreFoundation 0x18044fdcc CFRunLoopRun + 60 -8 CoreMotion 0x196a65300 0x196872000 + 2044672 -9 libsystem_pthread.dylib 0x102de263c _pthread_start + 104 -10 libsystem_pthread.dylib 0x102ddda34 thread_start + 8 - -Thread 3:: SBWiFiManager callback thread -0 libsystem_kernel.dylib 0x103038b70 mach_msg2_trap + 8 -1 libsystem_kernel.dylib 0x103049e5c mach_msg2_internal + 72 -2 libsystem_kernel.dylib 0x103040c44 mach_msg_overwrite + 484 -3 libsystem_kernel.dylib 0x103038ef0 mach_msg + 20 -4 CoreFoundation 0x180455c04 __CFRunLoopServiceMachPort + 156 -5 CoreFoundation 0x180454dbc __CFRunLoopRun + 1128 -6 CoreFoundation 0x18044fcec _CFRunLoopRunSpecificWithOptions + 496 -7 Foundation 0x18110be48 -[NSRunLoop(NSRunLoop) runMode:beforeDate:] + 208 -8 Foundation 0x18110c018 -[NSRunLoop(NSRunLoop) run] + 60 -9 SpringBoard 0x10537b890 -[SBWiFiManager _runManagerCallbackThread] + 180 -10 Foundation 0x181132d14 __NSThread__start__ + 716 -11 libsystem_pthread.dylib 0x102de263c _pthread_start + 104 -12 libsystem_pthread.dylib 0x102ddda34 thread_start + 8 - -Thread 4:: caulk.messenger.shared:17 -0 libsystem_kernel.dylib 0x103038aec semaphore_wait_trap + 8 -1 caulk 0x1ba9f1cb0 caulk::semaphore::timed_wait(double) + 220 -2 caulk 0x1ba9f9998 caulk::concurrent::details::worker_thread::run() + 28 -3 caulk 0x1ba9f9a0c void* caulk::thread_proxy>>(void*) + 48 -4 libsystem_pthread.dylib 0x102de263c _pthread_start + 104 -5 libsystem_pthread.dylib 0x102ddda34 thread_start + 8 - -Thread 5:: caulk.messenger.shared:high -0 libsystem_kernel.dylib 0x103038aec semaphore_wait_trap + 8 -1 caulk 0x1ba9f1cb0 caulk::semaphore::timed_wait(double) + 220 -2 caulk 0x1ba9f9998 caulk::concurrent::details::worker_thread::run() + 28 -3 caulk 0x1ba9f9a0c void* caulk::thread_proxy>>(void*) + 48 -4 libsystem_pthread.dylib 0x102de263c _pthread_start + 104 -5 libsystem_pthread.dylib 0x102ddda34 thread_start + 8 - -Thread 6:: com.apple.UIKit.inProcessAnimationManager -0 libsystem_kernel.dylib 0x103038aec semaphore_wait_trap + 8 -1 libdispatch.dylib 0x1801c2258 _dispatch_sema4_wait + 24 -2 libdispatch.dylib 0x1801c27e0 _dispatch_semaphore_wait_slow + 128 -3 UIKitCore 0x1856619c0 0x18519e000 + 4995520 -4 UIKitCore 0x185665e88 0x18519e000 + 5013128 -5 UIKitCore 0x1852f85d0 0x18519e000 + 1418704 -6 Foundation 0x181132d14 __NSThread__start__ + 716 -7 libsystem_pthread.dylib 0x102de263c _pthread_start + 104 -8 libsystem_pthread.dylib 0x102ddda34 thread_start + 8 - -Thread 7: - -Thread 8:: Dispatch queue: CFPreferences Remote KVO Callback Queue -0 libxpc.dylib 0x180120324 xpc_create_from_plist_with_string_cache + 148 -1 libxpc.dylib 0x18010c0e8 _xpc_copy_entitlements + 56 -2 libxpc.dylib 0x18010b790 xpc_copy_entitlement_for_self + 36 -3 CoreFoundation 0x18056e524 hasSharedPreferenceEntitlementForDomain + 44 -4 CoreFoundation 0x180589b7c -[CFPrefsPlistSource _isSharedInTheiOSSimulator] + 36 -5 CoreFoundation 0x18058ab04 -[CFPrefsPlistSource createSynchronizeMessage].cold.2 + 20 -6 CoreFoundation 0x18056edbc -[CFPrefsPlistSource createSynchronizeMessage] + 1252 -7 CoreFoundation 0x180570288 __62-[CFPrefsPlistSource createRequestNewContentMessageForDaemon:]_block_invoke + 40 -8 CoreFoundation 0x1805701e4 -[CFPrefsPlistSource createRequestNewContentMessageForDaemon:] + 332 -9 CoreFoundation 0x180520840 -[CFPrefsSearchListSource createRequestNewContentMessageForDaemon:] + 204 -10 CoreFoundation 0x180582ae8 -[CFPrefsSearchListSource alreadylocked_generationCountFromListOfSources:count:] + 164 -11 CoreFoundation 0x180582574 -[CFPrefsSearchListSource alreadylocked_getDictionary:] + 412 -12 CoreFoundation 0x18058314c -[CFPrefsSearchListSource generationCount].cold.1 + 52 -13 CoreFoundation 0x180521100 -[CFPrefsSearchListSource generationCount] + 92 -14 CoreFoundation 0x180521048 -[CFPrefsSearchListSource handleChangeNotificationForDomainIdentifier:isRemote:] + 300 -15 CoreFoundation 0x1805660b4 -[__NSFrozenDictionaryM __apply:context:] + 124 -16 CoreFoundation 0x1805223dc -[_CFXPreferences updateSearchListsForIdentifier:] + 172 -17 CoreFoundation 0x18055d970 __65-[_CFXPreferences _setupNewDaemonConnection:invalidationHandler:]_block_invoke_2 + 264 -18 libdispatch.dylib 0x1801c07a8 _dispatch_call_block_and_release + 24 -19 libdispatch.dylib 0x1801db4b0 _dispatch_client_callout + 12 -20 libdispatch.dylib 0x1801c9c28 _dispatch_lane_serial_drain + 984 -21 libdispatch.dylib 0x1801ca6e8 _dispatch_lane_invoke + 396 -22 libdispatch.dylib 0x1801d5534 _dispatch_root_queue_drain_deferred_wlh + 288 -23 libdispatch.dylib 0x1801d4c74 _dispatch_workloop_worker_thread + 692 -24 libsystem_pthread.dylib 0x102ddec28 _pthread_wqthread + 288 -25 libsystem_pthread.dylib 0x102ddda28 start_wqthread + 8 - -Thread 9 Crashed:: Dispatch queue: com.apple.root.user-initiated-qos -0 XCTAutomationSupport 0x178471730 __66-[XCTAutomationSession initWithAccessibilityFramework:dataSource:]_block_invoke + 184 -1 libdispatch.dylib 0x1801c07a8 _dispatch_call_block_and_release + 24 -2 libdispatch.dylib 0x1801db4b0 _dispatch_client_callout + 12 -3 libdispatch.dylib 0x1801f6824 + 28 -4 libdispatch.dylib 0x1801d3d58 _dispatch_root_queue_drain + 916 -5 libdispatch.dylib 0x1801d4510 _dispatch_worker_thread2 + 252 -6 libsystem_pthread.dylib 0x102ddebec _pthread_wqthread + 228 -7 libsystem_pthread.dylib 0x102ddda28 start_wqthread + 8 - -Thread 10: - - -Thread 9 crashed with ARM Thread State (64-bit): - x0: 0x0000000000000000 x1: 0x0000000000000001 x2: 0x00000001f26cea20 x3: 0x000060000214e3b0 - x4: 0x000060000214e400 x5: 0x000000000000003d x6: 0x0000000000000000 x7: 0x0000000000000000 - x8: 0x0000000000000000 x9: 0x000000007fffffff x10: 0x0000600002c4af80 x11: 0x0000000000000007 - x12: 0x0000000000000003 x13: 0x0000600002c4afa0 x14: 0x00000001f26cea20 x15: 0x00000001f26cea20 - x16: 0x00000001800920c4 x17: 0x00000001801c14bc x18: 0x0000000000000000 x19: 0x0000000000000000 - x20: 0x0000600001714d80 x21: 0xffffffffffffffff x22: 0x00000000000010ff x23: 0x00000000fff0ffff - x24: 0x0000600001750940 x25: 0x0000000000000000 x26: 0x0000000000000114 x27: 0x0000000000000004 - x28: 0x0000000000000000 fp: 0x000000016d3b2e60 lr: 0x0000000178471730 - sp: 0x000000016d3b2d00 pc: 0x0000000178471730 cpsr: 0x80001000 - far: 0x0000000000000020 esr: 0x92000006 (Data Abort) byte read Translation fault - -Binary Images: - 0x102b60000 - 0x102b77fff com.apple.springboard (1.0) /Volumes/VOLUME/*/SpringBoard.app/SpringBoard - 0x102ca4000 - 0x102cf3fff dyld_sim (*) /Volumes/VOLUME/*/dyld_sim - 0x105200000 - 0x106067fff com.apple.SpringBoardFramework (1.0) <3e8dd5fa-7d43-34ab-9cb5-4a20b7bf5f0b> /Volumes/VOLUME/*/SpringBoard.framework/SpringBoard - 0x102c4c000 - 0x102c57fff libsystem_platform.dylib (*) <0831b8d2-190f-31fc-9eb6-ea8ba11fe47b> /usr/lib/system/libsystem_platform.dylib - 0x103038000 - 0x103073fff libsystem_kernel.dylib (*) <856091df-df82-3985-9695-28eac306bcb5> /usr/lib/system/libsystem_kernel.dylib - 0x102ddc000 - 0x102debfff libsystem_pthread.dylib (*) <1e522024-387b-3d18-81ca-f4559198954b> /usr/lib/system/libsystem_pthread.dylib - 0x1030e4000 - 0x1030effff libobjc-trampolines.dylib (*) <997b234d-5c24-3e21-97d6-33b6853818c0> /Volumes/VOLUME/*/libobjc-trampolines.dylib - 0x11fea8000 - 0x11feabfff com.apple.ActivityRingsUI.axbundle (1.0) <82b5da39-1036-32a5-8c1d-227ea4bab0b7> /Volumes/VOLUME/*/ActivityRingsUI - 0x164d00000 - 0x164d1ffff com.apple.NanoTimeKitCompanion.axbundle (1.0) /Volumes/VOLUME/*/NanoTimeKitCompanion - 0x163bac000 - 0x163bb3fff com.apple.FitnessUI.axbundle (1.0) <4a165baa-a881-3837-8878-7e3693642844> /Volumes/VOLUME/*/FitnessUI - 0x178448000 - 0x178497fff com.apple.dt.XCTAutomationSupport (26.2) <3c283979-6a43-340a-92bb-cb0bd3e97973> /Volumes/VOLUME/*/XCTAutomationSupport.framework/XCTAutomationSupport - 0x163cd0000 - 0x163cd7fff com.apple.dt.XCTestSupport (26.2) <166640e5-7d3b-3277-8ddd-dd9ddca095ea> /Volumes/VOLUME/*/XCTestSupport.framework/XCTestSupport - 0x102ef8000 - 0x102f9ffff dyld (*) /usr/lib/dyld - 0x1803c3000 - 0x1807df37f com.apple.CoreFoundation (6.9) <4f6d050d-95ee-3a95-969c-3a98b29df6ff> /Volumes/VOLUME/*/CoreFoundation.framework/CoreFoundation - 0x192a64000 - 0x192a6bdbf com.apple.GraphicsServices (1.0) <4e5b0462-6170-3367-9475-4ff8b8dfe4e6> /Volumes/VOLUME/*/GraphicsServices.framework/GraphicsServices - 0x18519e000 - 0x1873c071f com.apple.UIKitCore (1.0) <196154ff-ba04-33cd-9277-98f9aa0b7499> /Volumes/VOLUME/*/UIKitCore.framework/UIKitCore - 0x0 - 0xffffffffffffffff ??? (*) <00000000-0000-0000-0000-000000000000> ??? - 0x18085f000 - 0x1815d18df com.apple.Foundation (6.9) /Volumes/VOLUME/*/Foundation.framework/Foundation - 0x196872000 - 0x196c0aaff com.apple.coremotion (3064.0.10) <530e3f46-9911-3734-8d02-9e3e7cbf1c36> /Volumes/VOLUME/*/CoreMotion.framework/CoreMotion - 0x1ba9e2000 - 0x1baa085bf com.apple.audio.caulk (1.0) <3e592a6d-e4ae-387e-9f93-b81c874443dc> /Volumes/VOLUME/*/caulk.framework/caulk - 0x1801bf000 - 0x1802041bf libdispatch.dylib (*) /Volumes/VOLUME/*/libdispatch.dylib - 0x1800f8000 - 0x18013d4ff libxpc.dylib (*) <5d3b42dd-89e9-36f1-8e20-f4bbf2b7644e> /Volumes/VOLUME/*/libxpc.dylib - 0x180070000 - 0x1800ad297 libobjc.A.dylib (*) <880f8664-cd53-3912-bdd5-5e3159295f7d> /Volumes/VOLUME/*/libobjc.A.dylib - -External Modification Summary: - Calls made by other processes targeting this process: - task_for_pid: 0 - thread_create: 0 - thread_set_state: 0 - Calls made by this process: - task_for_pid: 0 - thread_create: 0 - thread_set_state: 0 - Calls made by all processes on this machine: - task_for_pid: 112 - thread_create: 0 - thread_set_state: 0 - -VM Region Summary: -ReadOnly portion of Libraries: Total=2.2G resident=0K(0%) swapped_out_or_unallocated=2.2G(100%) -Writable regions: Total=1.9G written=2547K(0%) resident=2291K(0%) swapped_out=304K(0%) unallocated=1.9G(100%) - - VIRTUAL REGION -REGION TYPE SIZE COUNT (non-coalesced) -=========== ======= ======= -Accelerate framework 128K 1 -Activity Tracing 256K 1 -AttributeGraph Data 1024K 1 -CG raster data 2640K 46 -ColorSync 80K 5 -CoreAnimation 13.5M 17 -CoreUI image data 672K 3 -Foundation 144K 2 -IOSurface 18.9M 4 -Image IO 12.7M 22 -Kernel Alloc Once 32K 1 -MALLOC 1.9G 77 -MALLOC guard page 288K 18 -Memory Tag 240 48K 1 -SQLite page cache 640K 5 -STACK GUARD 56.2M 11 -Stack 13.3M 11 -VM_ALLOCATE 6496K 95 -__AUTH_CONST 32K 1 -__DATA 76.4M 1486 -__DATA_CONST 157.7M 1524 -__DATA_DIRTY 155K 14 -__FONT_DATA 1486K 2 -__LINKEDIT 721.8M 14 -__OBJC_RO 62.5M 1 -__OBJC_RW 2771K 1 -__TEXT 1.5G 1538 -__TPRO_CONST 164K 3 -dyld private memory 2.2G 171 -mapped file 291.7M 144 -page table in kernel 2291K 1 -shared memory 16K 1 -=========== ======= ======= -TOTAL 7.0G 5222 - - ------------ -Full Report ------------ - -{"app_name":"SpringBoard","timestamp":"2026-06-09 19:08:06.00 +0300","app_version":"1.0","slice_uuid":"f8bed36a-4d4c-3918-9708-0d2c1638edd0","build_version":"50","platform":7,"bundleID":"com.apple.springboard","share_with_app_devs":1,"is_first_party":0,"bug_type":"309","os_version":"macOS 26.5 (25F71)","roots_installed":0,"name":"SpringBoard","incident_id":"227F1D5E-A20F-4E31-94AA-6F555DF0050E"} -{ - "uptime" : 160000, - "procRole" : "Foreground", - "version" : 2, - "userID" : 501, - "deployVersion" : 210, - "modelCode" : "Mac14,9", - "coalitionID" : 11695, - "osVersion" : { - "train" : "macOS 26.5", - "build" : "25F71", - "releaseType" : "User" - }, - "captureTime" : "2026-06-09 19:07:52.1237 +0300", - "codeSigningMonitor" : 2, - "incident" : "227F1D5E-A20F-4E31-94AA-6F555DF0050E", - "pid" : 9731, - "translated" : false, - "cpuType" : "ARM-64", - "procLaunch" : "2026-06-09 17:33:55.7948 +0300", - "procStartAbsTime" : 3848618219810, - "procExitAbsTime" : 3983888341467, - "procName" : "SpringBoard", - "procPath" : "\/Volumes\/VOLUME\/*\/SpringBoard.app\/SpringBoard", - "bundleInfo" : {"CFBundleShortVersionString":"1.0","CFBundleVersion":"50","CFBundleIdentifier":"com.apple.springboard"}, - "storeInfo" : {"deviceIdentifierForVendor":"54D7CF33-29F3-57B4-853D-4752FD8F8E39","thirdParty":true}, - "parentProc" : "launchd_sim", - "parentPid" : 76361, - "coalitionName" : "com.apple.CoreSimulator.SimDevice.20173D7E-12AD-40DC-AA5E-54294B49121F", - "crashReporterKey" : "D8E09C9E-2577-6762-A15F-71FB31FAEC8C", - "appleIntelligenceStatus" : {"state":"available"}, - "developerMode" : 1, - "responsiblePid" : 1517, - "responsibleProc" : "SimulatorTrampoline", - "codeSigningID" : "com.apple.springboard", - "codeSigningTeamID" : "", - "codeSigningFlags" : 570442241, - "codeSigningValidationCategory" : 1, - "codeSigningTrustLevel" : 4294967295, - "codeSigningAuxiliaryInfo" : 0, - "instructionByteStream" : {"beforePC":"AgCAUkUAgFJtHACUAh0AlGCCAJHiHACU\/QMdqigdAJTzAwCqcR0AlA==","atPC":"YBJA+RsiAJT0AwCq4AMTqm8dAJTzHACUAOQAb+CDAq3ggwGtLh0AlA=="}, - "bootSessionUUID" : "639B423D-B90A-4C32-B622-636D4A819FBF", - "wakeTime" : 117338, - "sleepWakeUUID" : "C8733ABD-CE82-46DF-BB98-D1BE6663BD16", - "sip" : "enabled", - "vmRegionInfo" : "0x20 is not in any region. Bytes before following region: 4340449248\n REGION TYPE START - END [ VSIZE] PRT\/MAX SHRMOD REGION DETAIL\n UNUSED SPACE AT START\n---> \n __TEXT 102b60000-102b78000 [ 96K] r-x\/r-x SM=COW \/Volumes\/VOLUME\/*\/SpringBoard.app\/SpringBoard", - "exception" : {"codes":"0x0000000000000001, 0x0000000000000020","rawCodes":[1,32],"type":"EXC_BAD_ACCESS","signal":"SIGSEGV","subtype":"KERN_INVALID_ADDRESS at 0x0000000000000020"}, - "termination" : {"flags":0,"code":11,"namespace":"SIGNAL","indicator":"Segmentation fault: 11","byProc":"exc handler","byPid":9731}, - "vmregioninfo" : "0x20 is not in any region. Bytes before following region: 4340449248\n REGION TYPE START - END [ VSIZE] PRT\/MAX SHRMOD REGION DETAIL\n UNUSED SPACE AT START\n---> \n __TEXT 102b60000-102b78000 [ 96K] r-x\/r-x SM=COW \/Volumes\/VOLUME\/*\/SpringBoard.app\/SpringBoard", - "extMods" : {"caller":{"thread_create":0,"thread_set_state":0,"task_for_pid":0},"system":{"thread_create":0,"thread_set_state":0,"task_for_pid":112},"targeted":{"thread_create":0,"thread_set_state":0,"task_for_pid":0},"warnings":0}, - "faultingThread" : 9, - "threads" : [{"id":41963174,"threadState":{"x":[{"value":268451845},{"value":21592279046},{"value":8589934592},{"value":190142497161216},{"value":0},{"value":190142497161216},{"value":2},{"value":4294967295},{"value":0},{"value":17179869184},{"value":0},{"value":2},{"value":0},{"value":0},{"value":44271},{"value":3072},{"value":18446744073709551569},{"value":8},{"value":0},{"value":4294967295},{"value":2},{"value":190142497161216},{"value":0},{"value":190142497161216},{"value":21592279046},{"value":6126422120},{"value":8589934592},{"value":18446744073709550527},{"value":4345806848,"symbolLocation":0,"symbol":"_libkernel_string_functions"}],"flavor":"ARM_THREAD_STATE64","lr":{"value":4345601628},"cpsr":{"value":4096},"fp":{"value":6126421968},"sp":{"value":6126421888},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531248},"far":{"value":0}},"queue":"com.apple.main-thread","frames":[{"imageOffset":2928,"symbol":"mach_msg2_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":73308,"symbol":"mach_msg2_internal","symbolLocation":72,"imageIndex":4},{"imageOffset":35908,"symbol":"mach_msg_overwrite","symbolLocation":484,"imageIndex":4},{"imageOffset":3824,"symbol":"mach_msg","symbolLocation":20,"imageIndex":4},{"imageOffset":601092,"symbol":"__CFRunLoopServiceMachPort","symbolLocation":156,"imageIndex":13},{"imageOffset":597436,"symbol":"__CFRunLoopRun","symbolLocation":1128,"imageIndex":13},{"imageOffset":576748,"symbol":"_CFRunLoopRunSpecificWithOptions","symbolLocation":496,"imageIndex":13},{"imageOffset":10684,"symbol":"GSEventRunModal","symbolLocation":116,"imageIndex":14},{"imageOffset":18523508,"symbol":"-[UIApplication _run]","symbolLocation":772,"imageIndex":15},{"imageOffset":18540444,"symbol":"UIApplicationMain","symbolLocation":124,"imageIndex":15},{"imageOffset":36912,"symbol":"SBSystemAppMain","symbolLocation":4848,"imageIndex":2},{"imageOffset":5072,"symbol":"start_sim","symbolLocation":20,"imageIndex":1},{"imageOffset":130560,"symbol":"start","symbolLocation":6992,"imageIndex":12}]},{"id":41963682,"name":"com.apple.uikit.eventfetch-thread","threadState":{"x":[{"value":268451845},{"value":21592279046},{"value":8589934592},{"value":112300509888512},{"value":0},{"value":112300509888512},{"value":2},{"value":4294967295},{"value":0},{"value":17179869184},{"value":0},{"value":2},{"value":0},{"value":0},{"value":26147},{"value":3072},{"value":18446744073709551569},{"value":6448091312,"symbolLocation":0,"symbol":"-[NSConstantDate timeIntervalSinceReferenceDate]"},{"value":0},{"value":4294967295},{"value":2},{"value":112300509888512},{"value":0},{"value":112300509888512},{"value":21592279046},{"value":6136159624},{"value":8589934592},{"value":18446744073709550527},{"value":4345806848,"symbolLocation":0,"symbol":"_libkernel_string_functions"}],"flavor":"ARM_THREAD_STATE64","lr":{"value":4345601628},"cpsr":{"value":4096},"fp":{"value":6136159472},"sp":{"value":6136159392},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531248},"far":{"value":0}},"frames":[{"imageOffset":2928,"symbol":"mach_msg2_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":73308,"symbol":"mach_msg2_internal","symbolLocation":72,"imageIndex":4},{"imageOffset":35908,"symbol":"mach_msg_overwrite","symbolLocation":484,"imageIndex":4},{"imageOffset":3824,"symbol":"mach_msg","symbolLocation":20,"imageIndex":4},{"imageOffset":601092,"symbol":"__CFRunLoopServiceMachPort","symbolLocation":156,"imageIndex":13},{"imageOffset":597436,"symbol":"__CFRunLoopRun","symbolLocation":1128,"imageIndex":13},{"imageOffset":576748,"symbol":"_CFRunLoopRunSpecificWithOptions","symbolLocation":496,"imageIndex":13},{"imageOffset":9096776,"symbol":"-[NSRunLoop(NSRunLoop) runMode:beforeDate:]","symbolLocation":208,"imageIndex":17},{"imageOffset":9097320,"symbol":"-[NSRunLoop(NSRunLoop) runUntilDate:]","symbolLocation":60,"imageIndex":17},{"imageOffset":15735888,"symbol":"-[UIEventFetcher threadMain]","symbolLocation":392,"imageIndex":15},{"imageOffset":9256212,"symbol":"__NSThread__start__","symbolLocation":716,"imageIndex":17},{"imageOffset":26172,"symbol":"_pthread_start","symbolLocation":104,"imageIndex":5},{"imageOffset":6708,"symbol":"thread_start","symbolLocation":8,"imageIndex":5}]},{"id":41963690,"name":"com.apple.CoreMotion.MotionThread","threadState":{"x":[{"value":268451845},{"value":21592279046},{"value":8589934592},{"value":306776629051392},{"value":0},{"value":306776629051392},{"value":2},{"value":4294967295},{"value":0},{"value":17179869184},{"value":0},{"value":2},{"value":0},{"value":0},{"value":71427},{"value":3072},{"value":18446744073709551569},{"value":336},{"value":0},{"value":4294967295},{"value":2},{"value":306776629051392},{"value":0},{"value":306776629051392},{"value":21592279046},{"value":6136731928},{"value":8589934592},{"value":18446744073709550527},{"value":4345806848,"symbolLocation":0,"symbol":"_libkernel_string_functions"}],"flavor":"ARM_THREAD_STATE64","lr":{"value":4345601628},"cpsr":{"value":4096},"fp":{"value":6136731776},"sp":{"value":6136731696},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531248},"far":{"value":0}},"frames":[{"imageOffset":2928,"symbol":"mach_msg2_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":73308,"symbol":"mach_msg2_internal","symbolLocation":72,"imageIndex":4},{"imageOffset":35908,"symbol":"mach_msg_overwrite","symbolLocation":484,"imageIndex":4},{"imageOffset":3824,"symbol":"mach_msg","symbolLocation":20,"imageIndex":4},{"imageOffset":601092,"symbol":"__CFRunLoopServiceMachPort","symbolLocation":156,"imageIndex":13},{"imageOffset":597436,"symbol":"__CFRunLoopRun","symbolLocation":1128,"imageIndex":13},{"imageOffset":576748,"symbol":"_CFRunLoopRunSpecificWithOptions","symbolLocation":496,"imageIndex":13},{"imageOffset":576972,"symbol":"CFRunLoopRun","symbolLocation":60,"imageIndex":13},{"imageOffset":2044672,"imageIndex":18},{"imageOffset":26172,"symbol":"_pthread_start","symbolLocation":104,"imageIndex":5},{"imageOffset":6708,"symbol":"thread_start","symbolLocation":8,"imageIndex":5}]},{"id":41963881,"name":"SBWiFiManager callback thread","threadState":{"x":[{"value":268451845},{"value":21592279046},{"value":8589934592},{"value":516783349956608},{"value":0},{"value":516783349956608},{"value":2},{"value":4294967295},{"value":0},{"value":17179869184},{"value":0},{"value":2},{"value":0},{"value":0},{"value":120323},{"value":3072},{"value":18446744073709551569},{"value":6448091312,"symbolLocation":0,"symbol":"-[NSConstantDate timeIntervalSinceReferenceDate]"},{"value":0},{"value":4294967295},{"value":2},{"value":516783349956608},{"value":0},{"value":516783349956608},{"value":21592279046},{"value":6137306520},{"value":8589934592},{"value":18446744073709550527},{"value":4345806848,"symbolLocation":0,"symbol":"_libkernel_string_functions"}],"flavor":"ARM_THREAD_STATE64","lr":{"value":4345601628},"cpsr":{"value":4096},"fp":{"value":6137306368},"sp":{"value":6137306288},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531248},"far":{"value":0}},"frames":[{"imageOffset":2928,"symbol":"mach_msg2_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":73308,"symbol":"mach_msg2_internal","symbolLocation":72,"imageIndex":4},{"imageOffset":35908,"symbol":"mach_msg_overwrite","symbolLocation":484,"imageIndex":4},{"imageOffset":3824,"symbol":"mach_msg","symbolLocation":20,"imageIndex":4},{"imageOffset":601092,"symbol":"__CFRunLoopServiceMachPort","symbolLocation":156,"imageIndex":13},{"imageOffset":597436,"symbol":"__CFRunLoopRun","symbolLocation":1128,"imageIndex":13},{"imageOffset":576748,"symbol":"_CFRunLoopRunSpecificWithOptions","symbolLocation":496,"imageIndex":13},{"imageOffset":9096776,"symbol":"-[NSRunLoop(NSRunLoop) runMode:beforeDate:]","symbolLocation":208,"imageIndex":17},{"imageOffset":9097240,"symbol":"-[NSRunLoop(NSRunLoop) run]","symbolLocation":60,"imageIndex":17},{"imageOffset":1554576,"symbol":"-[SBWiFiManager _runManagerCallbackThread]","symbolLocation":180,"imageIndex":2},{"imageOffset":9256212,"symbol":"__NSThread__start__","symbolLocation":716,"imageIndex":17},{"imageOffset":26172,"symbol":"_pthread_start","symbolLocation":104,"imageIndex":5},{"imageOffset":6708,"symbol":"thread_start","symbolLocation":8,"imageIndex":5}]},{"id":41963885,"name":"caulk.messenger.shared:17","threadState":{"x":[{"value":14},{"value":105553119886938},{"value":0},{"value":6137884778},{"value":105553119886912},{"value":25},{"value":0},{"value":0},{"value":0},{"value":4294967295},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":18446744073709551580},{"value":0},{"value":0},{"value":105553176966176},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0}],"flavor":"ARM_THREAD_STATE64","lr":{"value":7425957040},"cpsr":{"value":2147487744},"fp":{"value":6137884544},"sp":{"value":6137884512},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531116},"far":{"value":0}},"frames":[{"imageOffset":2796,"symbol":"semaphore_wait_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":64688,"symbol":"caulk::semaphore::timed_wait(double)","symbolLocation":220,"imageIndex":19},{"imageOffset":96664,"symbol":"caulk::concurrent::details::worker_thread::run()","symbolLocation":28,"imageIndex":19},{"imageOffset":96780,"symbol":"void* caulk::thread_proxy>>(void*)","symbolLocation":48,"imageIndex":19},{"imageOffset":26172,"symbol":"_pthread_start","symbolLocation":104,"imageIndex":5},{"imageOffset":6708,"symbol":"thread_start","symbolLocation":8,"imageIndex":5}]},{"id":41963886,"name":"caulk.messenger.shared:high","threadState":{"x":[{"value":14},{"value":105553119886876},{"value":0},{"value":6138458220},{"value":105553119886848},{"value":27},{"value":0},{"value":0},{"value":0},{"value":4294967295},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":18446744073709551580},{"value":0},{"value":0},{"value":105553176966384},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0}],"flavor":"ARM_THREAD_STATE64","lr":{"value":7425957040},"cpsr":{"value":2147487744},"fp":{"value":6138457984},"sp":{"value":6138457952},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531116},"far":{"value":0}},"frames":[{"imageOffset":2796,"symbol":"semaphore_wait_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":64688,"symbol":"caulk::semaphore::timed_wait(double)","symbolLocation":220,"imageIndex":19},{"imageOffset":96664,"symbol":"caulk::concurrent::details::worker_thread::run()","symbolLocation":28,"imageIndex":19},{"imageOffset":96780,"symbol":"void* caulk::thread_proxy>>(void*)","symbolLocation":48,"imageIndex":19},{"imageOffset":26172,"symbol":"_pthread_start","symbolLocation":104,"imageIndex":5},{"imageOffset":6708,"symbol":"thread_start","symbolLocation":8,"imageIndex":5}]},{"id":41964411,"name":"com.apple.UIKit.inProcessAnimationManager","threadState":{"x":[{"value":14},{"value":18446744073709551615},{"value":1},{"value":1},{"value":17179869187},{"value":16},{"value":17179869187},{"value":16},{"value":251139},{"value":18446744073709551615},{"value":0},{"value":0},{"value":8589934595},{"value":16},{"value":8362150760,"symbolLocation":0,"symbol":"OBJC_CLASS_$_OS_dispatch_semaphore"},{"value":8362150760,"symbolLocation":0,"symbol":"OBJC_CLASS_$_OS_dispatch_semaphore"},{"value":18446744073709551580},{"value":6444291256,"symbolLocation":0,"symbol":"-[OS_object retain]"},{"value":0},{"value":105553152225216},{"value":105553152225152},{"value":18446744073709551615},{"value":4350578992},{"value":8512434176,"objc-selector":"OfBytesUsingEncoding:"},{"value":8512434176,"objc-selector":"OfBytesUsingEncoding:"},{"value":105553152225152},{"value":0},{"value":0},{"value":0}],"flavor":"ARM_THREAD_STATE64","lr":{"value":6444294744},"cpsr":{"value":1610616832},"fp":{"value":6149352512},"sp":{"value":6149352496},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4345531116},"far":{"value":0}},"frames":[{"imageOffset":2796,"symbol":"semaphore_wait_trap","symbolLocation":8,"imageIndex":4},{"imageOffset":12888,"symbol":"_dispatch_sema4_wait","symbolLocation":24,"imageIndex":20},{"imageOffset":14304,"symbol":"_dispatch_semaphore_wait_slow","symbolLocation":128,"imageIndex":20},{"imageOffset":4995520,"imageIndex":15},{"imageOffset":5013128,"imageIndex":15},{"imageOffset":1418704,"imageIndex":15},{"imageOffset":9256212,"symbol":"__NSThread__start__","symbolLocation":716,"imageIndex":17},{"imageOffset":26172,"symbol":"_pthread_start","symbolLocation":104,"imageIndex":5},{"imageOffset":6708,"symbol":"thread_start","symbolLocation":8,"imageIndex":5}]},{"id":43943504,"frames":[],"threadState":{"x":[{"value":6129283072},{"value":226667},{"value":6128746496},{"value":0},{"value":409605},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0}],"flavor":"ARM_THREAD_STATE64","lr":{"value":0},"cpsr":{"value":4096},"fp":{"value":0},"sp":{"value":6129283072},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4343061024},"far":{"value":0}}},{"id":43943505,"threadState":{"x":[{"value":4789846016},{"value":47455},{"value":0},{"value":0},{"value":9},{"value":0},{"value":0},{"value":4048},{"value":1768714338},{"value":29811},{"value":8300},{"value":1},{"value":43},{"value":11400714819323198549},{"value":11400714819323198549},{"value":2753599736},{"value":6442931180,"symbolLocation":0,"symbol":"object_getClass"},{"value":2},{"value":0},{"value":47455},{"value":4789846016},{"value":8148343872},{"value":8148343776},{"value":0},{"value":8148343872},{"value":105553162561792},{"value":8362339256,"symbolLocation":0,"symbol":"OBJC_IVAR_$_CFPrefsSource._lock"},{"value":0},{"value":3}],"flavor":"ARM_THREAD_STATE64","lr":{"value":6443548904},"cpsr":{"value":4096},"fp":{"value":6131571936},"sp":{"value":6131571648},"esr":{"value":2449473607,"description":"(Data Abort) byte write Translation fault"},"pc":{"value":6443631396},"far":{"value":0}},"queue":"CFPreferences Remote KVO Callback Queue","frames":[{"imageOffset":164644,"symbol":"xpc_create_from_plist_with_string_cache","symbolLocation":148,"imageIndex":21},{"imageOffset":82152,"symbol":"_xpc_copy_entitlements","symbolLocation":56,"imageIndex":21},{"imageOffset":79760,"symbol":"xpc_copy_entitlement_for_self","symbolLocation":36,"imageIndex":21},{"imageOffset":1750308,"symbol":"hasSharedPreferenceEntitlementForDomain","symbolLocation":44,"imageIndex":13},{"imageOffset":1862524,"symbol":"-[CFPrefsPlistSource _isSharedInTheiOSSimulator]","symbolLocation":36,"imageIndex":13},{"imageOffset":1866500,"symbol":"-[CFPrefsPlistSource createSynchronizeMessage].cold.2","symbolLocation":20,"imageIndex":13},{"imageOffset":1752508,"symbol":"-[CFPrefsPlistSource createSynchronizeMessage]","symbolLocation":1252,"imageIndex":13},{"imageOffset":1757832,"symbol":"__62-[CFPrefsPlistSource createRequestNewContentMessageForDaemon:]_block_invoke","symbolLocation":40,"imageIndex":13},{"imageOffset":1757668,"symbol":"-[CFPrefsPlistSource createRequestNewContentMessageForDaemon:]","symbolLocation":332,"imageIndex":13},{"imageOffset":1431616,"symbol":"-[CFPrefsSearchListSource createRequestNewContentMessageForDaemon:]","symbolLocation":204,"imageIndex":13},{"imageOffset":1833704,"symbol":"-[CFPrefsSearchListSource alreadylocked_generationCountFromListOfSources:count:]","symbolLocation":164,"imageIndex":13},{"imageOffset":1832308,"symbol":"-[CFPrefsSearchListSource alreadylocked_getDictionary:]","symbolLocation":412,"imageIndex":13},{"imageOffset":1835340,"symbol":"-[CFPrefsSearchListSource generationCount].cold.1","symbolLocation":52,"imageIndex":13},{"imageOffset":1433856,"symbol":"-[CFPrefsSearchListSource generationCount]","symbolLocation":92,"imageIndex":13},{"imageOffset":1433672,"symbol":"-[CFPrefsSearchListSource handleChangeNotificationForDomainIdentifier:isRemote:]","symbolLocation":300,"imageIndex":13},{"imageOffset":1716404,"symbol":"-[__NSFrozenDictionaryM __apply:context:]","symbolLocation":124,"imageIndex":13},{"imageOffset":1438684,"symbol":"-[_CFXPreferences updateSearchListsForIdentifier:]","symbolLocation":172,"imageIndex":13},{"imageOffset":1681776,"symbol":"__65-[_CFXPreferences _setupNewDaemonConnection:invalidationHandler:]_block_invoke_2","symbolLocation":264,"imageIndex":13},{"imageOffset":6056,"symbol":"_dispatch_call_block_and_release","symbolLocation":24,"imageIndex":20},{"imageOffset":115888,"symbol":"_dispatch_client_callout","symbolLocation":12,"imageIndex":20},{"imageOffset":44072,"symbol":"_dispatch_lane_serial_drain","symbolLocation":984,"imageIndex":20},{"imageOffset":46824,"symbol":"_dispatch_lane_invoke","symbolLocation":396,"imageIndex":20},{"imageOffset":91444,"symbol":"_dispatch_root_queue_drain_deferred_wlh","symbolLocation":288,"imageIndex":20},{"imageOffset":89204,"symbol":"_dispatch_workloop_worker_thread","symbolLocation":692,"imageIndex":20},{"imageOffset":11304,"symbol":"_pthread_wqthread","symbolLocation":288,"imageIndex":5},{"imageOffset":6696,"symbol":"start_wqthread","symbolLocation":8,"imageIndex":5}]},{"triggered":true,"id":43952736,"threadState":{"x":[{"value":0},{"value":1},{"value":8362191392,"symbolLocation":0,"symbol":"OBJC_CLASS_$_OS_os_log"},{"value":105553151189936},{"value":105553151190016},{"value":61},{"value":0},{"value":0},{"value":0},{"value":2147483647},{"value":105553162710912},{"value":7},{"value":3},{"value":105553162710944},{"value":8362191392,"symbolLocation":0,"symbol":"OBJC_CLASS_$_OS_os_log"},{"value":8362191392,"symbolLocation":0,"symbol":"OBJC_CLASS_$_OS_os_log"},{"value":6443049156,"symbolLocation":0,"symbol":"objc_sync_enter"},{"value":6444291260,"symbolLocation":0,"symbol":"-[OS_object release]"},{"value":0},{"value":0},{"value":105553140469120},{"value":18446744073709551615},{"value":4351},{"value":4293984255},{"value":105553140713792},{"value":0},{"value":276},{"value":4},{"value":0}],"flavor":"ARM_THREAD_STATE64","lr":{"value":6312892208},"cpsr":{"value":2147487744},"fp":{"value":6127562336},"sp":{"value":6127561984},"esr":{"value":2449473542,"description":"(Data Abort) byte read Translation fault"},"pc":{"value":6312892208,"matchesCrashFrame":1},"far":{"value":32}},"queue":"com.apple.root.user-initiated-qos","frames":[{"imageOffset":169776,"symbol":"__66-[XCTAutomationSession initWithAccessibilityFramework:dataSource:]_block_invoke","symbolLocation":184,"imageIndex":10},{"imageOffset":6056,"symbol":"_dispatch_call_block_and_release","symbolLocation":24,"imageIndex":20},{"imageOffset":115888,"symbol":"_dispatch_client_callout","symbolLocation":12,"imageIndex":20},{"imageOffset":227364,"symbol":"","symbolLocation":28,"imageIndex":20},{"imageOffset":85336,"symbol":"_dispatch_root_queue_drain","symbolLocation":916,"imageIndex":20},{"imageOffset":87312,"symbol":"_dispatch_worker_thread2","symbolLocation":252,"imageIndex":20},{"imageOffset":11244,"symbol":"_pthread_wqthread","symbolLocation":228,"imageIndex":5},{"imageOffset":6696,"symbol":"start_wqthread","symbolLocation":8,"imageIndex":5}]},{"id":43961947,"frames":[],"threadState":{"x":[{"value":6126989312},{"value":89667},{"value":6126452736},{"value":0},{"value":409604},{"value":18446744073709551615},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0},{"value":0}],"flavor":"ARM_THREAD_STATE64","lr":{"value":0},"cpsr":{"value":4096},"fp":{"value":0},"sp":{"value":6126989312},"esr":{"value":1442840704,"description":"(Syscall)"},"pc":{"value":4343061024},"far":{"value":0}}}], - "usedImages" : [ - { - "source" : "P", - "arch" : "arm64", - "base" : 4340449280, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.springboard", - "size" : 98304, - "uuid" : "f8bed36a-4d4c-3918-9708-0d2c1638edd0", - "path" : "\/Volumes\/VOLUME\/*\/SpringBoard.app\/SpringBoard", - "name" : "SpringBoard", - "CFBundleVersion" : "50" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4341776384, - "size" : 327680, - "uuid" : "fdf77ecc-13ab-3a08-bee2-9a65ededc16e", - "path" : "\/Volumes\/VOLUME\/*\/dyld_sim", - "name" : "dyld_sim" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4380950528, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.SpringBoardFramework", - "size" : 15106048, - "uuid" : "3e8dd5fa-7d43-34ab-9cb5-4a20b7bf5f0b", - "path" : "\/Volumes\/VOLUME\/*\/SpringBoard.framework\/SpringBoard", - "name" : "SpringBoard", - "CFBundleVersion" : "1" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4341415936, - "size" : 49152, - "uuid" : "0831b8d2-190f-31fc-9eb6-ea8ba11fe47b", - "path" : "\/usr\/lib\/system\/libsystem_platform.dylib", - "name" : "libsystem_platform.dylib" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4345528320, - "size" : 245760, - "uuid" : "856091df-df82-3985-9695-28eac306bcb5", - "path" : "\/usr\/lib\/system\/libsystem_kernel.dylib", - "name" : "libsystem_kernel.dylib" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4343054336, - "size" : 65536, - "uuid" : "1e522024-387b-3d18-81ca-f4559198954b", - "path" : "\/usr\/lib\/system\/libsystem_pthread.dylib", - "name" : "libsystem_pthread.dylib" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4346232832, - "size" : 49152, - "uuid" : "997b234d-5c24-3e21-97d6-33b6853818c0", - "path" : "\/Volumes\/VOLUME\/*\/libobjc-trampolines.dylib", - "name" : "libobjc-trampolines.dylib" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 4830429184, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.ActivityRingsUI.axbundle", - "size" : 16384, - "uuid" : "82b5da39-1036-32a5-8c1d-227ea4bab0b7", - "path" : "\/Volumes\/VOLUME\/*\/ActivityRingsUI", - "name" : "ActivityRingsUI", - "CFBundleVersion" : "1" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 5986320384, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.NanoTimeKitCompanion.axbundle", - "size" : 131072, - "uuid" : "d229e30a-f57c-326c-b62f-5032a1719f42", - "path" : "\/Volumes\/VOLUME\/*\/NanoTimeKitCompanion", - "name" : "NanoTimeKitCompanion", - "CFBundleVersion" : "1" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 5968150528, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.FitnessUI.axbundle", - "size" : 32768, - "uuid" : "4a165baa-a881-3837-8878-7e3693642844", - "path" : "\/Volumes\/VOLUME\/*\/FitnessUI", - "name" : "FitnessUI", - "CFBundleVersion" : "1" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6312722432, - "CFBundleShortVersionString" : "26.2", - "CFBundleIdentifier" : "com.apple.dt.XCTAutomationSupport", - "size" : 327680, - "uuid" : "3c283979-6a43-340a-92bb-cb0bd3e97973", - "path" : "\/Volumes\/VOLUME\/*\/XCTAutomationSupport.framework\/XCTAutomationSupport", - "name" : "XCTAutomationSupport", - "CFBundleVersion" : "24507" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 5969346560, - "CFBundleShortVersionString" : "26.2", - "CFBundleIdentifier" : "com.apple.dt.XCTestSupport", - "size" : 32768, - "uuid" : "166640e5-7d3b-3277-8ddd-dd9ddca095ea", - "path" : "\/Volumes\/VOLUME\/*\/XCTestSupport.framework\/XCTestSupport", - "name" : "XCTestSupport", - "CFBundleVersion" : "24507" - }, - { - "source" : "P", - "arch" : "arm64e", - "base" : 4344217600, - "size" : 688128, - "uuid" : "a237ef81-b68b-37ba-a165-92c965529534", - "path" : "\/usr\/lib\/dyld", - "name" : "dyld" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6446395392, - "CFBundleShortVersionString" : "6.9", - "CFBundleIdentifier" : "com.apple.CoreFoundation", - "size" : 4309888, - "uuid" : "4f6d050d-95ee-3a95-969c-3a98b29df6ff", - "path" : "\/Volumes\/VOLUME\/*\/CoreFoundation.framework\/CoreFoundation", - "name" : "CoreFoundation", - "CFBundleVersion" : "4201" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6755336192, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.GraphicsServices", - "size" : 32192, - "uuid" : "4e5b0462-6170-3367-9475-4ff8b8dfe4e6", - "path" : "\/Volumes\/VOLUME\/*\/GraphicsServices.framework\/GraphicsServices", - "name" : "GraphicsServices", - "CFBundleVersion" : "1.0" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6528032768, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.UIKitCore", - "size" : 35792672, - "uuid" : "196154ff-ba04-33cd-9277-98f9aa0b7499", - "path" : "\/Volumes\/VOLUME\/*\/UIKitCore.framework\/UIKitCore", - "name" : "UIKitCore", - "CFBundleVersion" : "9126.2.4.1.111" - }, - { - "size" : 0, - "source" : "A", - "base" : 0, - "uuid" : "00000000-0000-0000-0000-000000000000" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6451228672, - "CFBundleShortVersionString" : "6.9", - "CFBundleIdentifier" : "com.apple.Foundation", - "size" : 14100704, - "uuid" : "c153116f-dd31-3fa9-89bb-04b47c1fa83d", - "path" : "\/Volumes\/VOLUME\/*\/Foundation.framework\/Foundation", - "name" : "Foundation", - "CFBundleVersion" : "4201" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6820405248, - "CFBundleShortVersionString" : "3064.0.10", - "CFBundleIdentifier" : "com.apple.coremotion", - "size" : 3771136, - "uuid" : "530e3f46-9911-3734-8d02-9e3e7cbf1c36", - "path" : "\/Volumes\/VOLUME\/*\/CoreMotion.framework\/CoreMotion", - "name" : "CoreMotion", - "CFBundleVersion" : "3064.0.10" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 7425892352, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.audio.caulk", - "size" : 157120, - "uuid" : "3e592a6d-e4ae-387e-9f93-b81c874443dc", - "path" : "\/Volumes\/VOLUME\/*\/caulk.framework\/caulk", - "name" : "caulk" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6444281856, - "size" : 283072, - "uuid" : "ec9ecf10-959d-3da1-a055-6de970159b9d", - "path" : "\/Volumes\/VOLUME\/*\/libdispatch.dylib", - "name" : "libdispatch.dylib" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6443466752, - "size" : 283904, - "uuid" : "5d3b42dd-89e9-36f1-8e20-f4bbf2b7644e", - "path" : "\/Volumes\/VOLUME\/*\/libxpc.dylib", - "name" : "libxpc.dylib" - }, - { - "source" : "P", - "arch" : "arm64", - "base" : 6442909696, - "size" : 250520, - "uuid" : "880f8664-cd53-3912-bdd5-5e3159295f7d", - "path" : "\/Volumes\/VOLUME\/*\/libobjc.A.dylib", - "name" : "libobjc.A.dylib" - } -], - "sharedCache" : { - "base" : 6442450944, - "size" : 4230250496, - "uuid" : "b41e164e-5c92-3cd9-be0d-2c616b2554f3" -}, - "vmSummary" : "ReadOnly portion of Libraries: Total=2.2G resident=0K(0%) swapped_out_or_unallocated=2.2G(100%)\nWritable regions: Total=1.9G written=2547K(0%) resident=2291K(0%) swapped_out=304K(0%) unallocated=1.9G(100%)\n\n VIRTUAL REGION \nREGION TYPE SIZE COUNT (non-coalesced) \n=========== ======= ======= \nAccelerate framework 128K 1 \nActivity Tracing 256K 1 \nAttributeGraph Data 1024K 1 \nCG raster data 2640K 46 \nColorSync 80K 5 \nCoreAnimation 13.5M 17 \nCoreUI image data 672K 3 \nFoundation 144K 2 \nIOSurface 18.9M 4 \nImage IO 12.7M 22 \nKernel Alloc Once 32K 1 \nMALLOC 1.9G 77 \nMALLOC guard page 288K 18 \nMemory Tag 240 48K 1 \nSQLite page cache 640K 5 \nSTACK GUARD 56.2M 11 \nStack 13.3M 11 \nVM_ALLOCATE 6496K 95 \n__AUTH_CONST 32K 1 \n__DATA 76.4M 1486 \n__DATA_CONST 157.7M 1524 \n__DATA_DIRTY 155K 14 \n__FONT_DATA 1486K 2 \n__LINKEDIT 721.8M 14 \n__OBJC_RO 62.5M 1 \n__OBJC_RW 2771K 1 \n__TEXT 1.5G 1538 \n__TPRO_CONST 164K 3 \ndyld private memory 2.2G 171 \nmapped file 291.7M 144 \npage table in kernel 2291K 1 \nshared memory 16K 1 \n=========== ======= ======= \nTOTAL 7.0G 5222 \n", - "legacyInfo" : { - "threadTriggered" : { - "queue" : "com.apple.root.user-initiated-qos" - } -}, - "logWritingSignature" : "412b35473ddd856151e0b371a0ddc644c9685dc5", - "roots_installed" : 0, - "bug_type" : "309", - "trmStatus" : 1, - "voucherInfos" : [{"originatorName":"testmanagerd","proximateName":"cfprefsd","thread_id":43943505}], - "trialInfo" : { - "rollouts" : [ - { - "rolloutId" : "67648e5334a82511f4acf879", - "factorPackIds" : [ - - ], - "deploymentId" : 240000008 - }, - { - "rolloutId" : "67181b10c68c361a728c7cfa", - "factorPackIds" : [ - - ], - "deploymentId" : 240000005 - } - ], - "experiments" : [ - - ] -} -} - -Model: Mac14,9, BootROM 18000.120.36, proc 10:6:4:0 processors, 32 GB, SMC -Graphics: Apple M2 Pro, Apple M2 Pro, Built-In -Display: Color LCD, 3024 x 1964 Retina, Main, MirrorOff, Online -Memory Module: LPDDR5, Hynix -AirPort: spairport_wireless_card_type_wifi (0x14E4, 0x4388), wl0: Feb 2 2026 19:18:00 version 23.50.20.0.41.51.208 FWID 01-3e23bec0 -IO80211_driverkit-1561.3 "IO80211_driverkit-1561.3" Apr 18 2026 17:42:26 -AirPort: -Bluetooth: Version (null), 0 services, 0 devices, 0 incoming serial ports -Network Service: Wi-Fi, AirPort, en0 -Network Service: Tailscale, VPN (io.tailscale.ipn.macsys), utun8 -Thunderbolt Bus: MacBook Pro, Apple Inc. -Thunderbolt Bus: MacBook Pro, Apple Inc. -Thunderbolt Bus: MacBook Pro, Apple Inc. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5c4540b1..71ad6565 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -16,23 +16,23 @@ Once a bug is fixed, leave its entry in place and move the status marker to ✅ | Bug 2 | Hub tree node open: loader + long-list render stall | Open (split — see Issue 1 + Issue 2) | | Bug 3 | Quick Access strip never loads items | ✅ DONE 2026-05-16 (eea502d) | | Bug 4 | Long conversation: scroll-to-end flickery / jumpy | ✅ DONE 2026-05-22 (78218fb) | -| Bug 5 | Multi-attachment send produces no output | Open — not diagnosed | +| Bug 5 | Multi-attachment send produces no output | 🔄 In flight — [PR #345](https://github.com/RonenMars/threadbase-mobile/pull/345) (spaces in `@path`; pairs with streamer #241) | | Bug 6 | Conversation list content hidden under bottom action bar | ✅ DONE 2026-05-24 (PR #11, commit 9835ecf) | -| Bug 7 | Quick Access strip: default-collapsed + tab reorder + hide when empty | Open | -| Bug 8 | Manage Favorites: duplicate top bar (8b CTA moved to Feature 24) | Open | -| Bug 9 | Quick Access: hide Edit pencil when strip is collapsed | Open | +| Bug 7 | Quick Access strip: default-collapsed + tab reorder + hide when empty | ✅ DONE — Favorites-only strip (`c43e9a1`); remaining Recents routing superseded | +| Bug 8 | Manage Favorites: duplicate top bar (8b CTA moved to Feature 24) | ✅ DONE — single native Stack header | +| Bug 9 | Quick Access: hide Edit pencil when strip is collapsed | ✅ DONE — pencil/gear gated on `!stripCollapsed` | | Bug 10 | Conversation: show "Top" button only when scrolling up | ✅ DONE 2026-05-24 (384790c) | | Bug 11 | Conversation: move "Bottom" button to bottom-right; show only when not at bottom | ✅ DONE 2026-05-24 (384790c) | | Bug 12 | MessageBubble bleed + code-fence collapse cut | ✅ DONE 2026-05-22 (cdf0303, d3aec11, f58d74d, 1a020fb) | -| Bug 13 | New session: name modal flashes open then auto-closes before user can type | Open — not diagnosed | -| Bug 14 | After starting new session, file browser stays in stack and re-shows on exit | Open — not diagnosed | -| Bug 15 | After new-session back, file browser is interaction-locked (only close works) | Open — not diagnosed | -| Bug 16 | Back from never-typed-in new session leaves an empty session alive | Open — not diagnosed | -| Bug 17 | Chat output + on-reconnect: scroll-to-bottom is jumpy, not smooth | Open — not diagnosed | -| Bug 18 | Maestro flow `server_drag_reorder.yaml.skip` crashes the app at `swipe` | Partial — flow recreated as `.yaml` (cd6d753), `.skip` deleted; still needs CI re-wiring | +| Bug 13 | New session: name modal flashes open then auto-closes before user can type | ♻️ Replaced — create/exit name modals removed (`6c138c8`); rename remains | +| Bug 14 | After starting new session, file browser stays in stack and re-shows on exit | ✅ DONE — dismiss-then-push `/session/new` (`0909404`) | +| Bug 15 | After new-session back, file browser is interaction-locked (only close works) | ✅ DONE — eliminated with Bug 14 navigation | +| Bug 16 | Back from never-typed-in new session leaves an empty session alive | ✅ DONE on integration — stop unused fresh PTY on `beforeRemove` (`a17a2f2`); public PR #346 | +| Bug 17 | Chat output + on-reconnect: scroll-to-bottom is jumpy, not smooth | 🟡 Partial — live list uses native MVCP (`da5638c` / #382); reconnect also rehydrates bound conversation (U1) | +| Bug 18 | Maestro flow `server_drag_reorder` — swipe crash / suite wiring | ✅ DONE — wired into `test:e2e:mock`; swipe removed (reorder covered by integration tests) | | Bug 19 | Maestro flow `tree_server_headers.yaml.skip` can't return to hub after second pair | Open — flow skipped | | Bug 20 | New session from tree-view (with path completion): "Path" error | Open — not diagnosed | -| Bug 21 | "Open Session" from Recents lands on "Session not found" | Open — not diagnosed | +| Bug 21 | "Open Session" from Recents lands on "Session not found" | ✅ DONE — Recents removed; U1 evicts stale session/conversation favorites on 404 + recovery CTA | | Bug 22 | Settings QR-scanner button is a no-op on the UI layer | Open — not diagnosed | | Bug 23 | Popular → "New Session here" errors "Unable to load directories" | Open — not diagnosed | | Bug 24 | Popular error text is black on black (almost invisible) | Open (visual — consult /impeccable) | @@ -45,11 +45,11 @@ Once a bug is fixed, leave its entry in place and move the status marker to ✅ | Bug 31 | Settings theme change doesn't apply colors across the whole app | Open — not diagnosed | | Bug 32 | One unavailable server hides conversations from all servers (Hub + search) | ✅ DONE 2026-05-27 (1ec1686) | | Bug 33 | Browse→session navigation: simplify the transitionEnd dismiss-then-push dance | Open — investigation | -| Issue 1 | Post-intro: cached Hub list flashes, then re-paints with server data | Open | +| Issue 1 | Post-intro: cached Hub list flashes, then re-paints with server data | ✅ DONE — cached-data refresh indicator (`2a3e6d8` / #326) | | Issue 2 | Hub accordion expand stalls on long projects (1,266 items → ~9 s) | Open | | E2E | Mock Maestro suite — remaining flow failures + env gotchas | Open — see [e2e-remaining-work.md](./e2e-remaining-work.md) | -**Suggested next-up order:** **Bug 32** (one bad server hides everything — mechanical `Promise.allSettled` fix, ship standalone) → Bug 13 + Bug 14 + Bug 15 + Bug 16 (all on the browse → start-session → PTY → back handoff, likely shared root cause — investigate together) → Bug 7 → Bug 9 (same file, ship together) → Bug 8 → **Bug 17 → Bug 10 + Bug 11** (same conversation FlashList; smooth-scroll fix may change at-bottom detection the scroll-button bugs rely on) → Issue 1 → Issue 2 → Bug 6 → Bug 5. Rationale at the bottom of the file under [Sequencing](#sequencing). +**Suggested next-up order (2026-07-22):** Merge in-flight pre-release PRs first (**Bug 5** #345, **Bug 16** #346, plus privacy Features 35/36 via #343). Then open new work in this order: **(1) Expo Router typed-route / `npm run typecheck` red** (required CI) → **(2) Maestro release suite green** (Feature 17 + [e2e-remaining-work.md](./e2e-remaining-work.md) + Bugs 18/19) → **(3) Feature 5 — onboarding polish** (OSS first-impression). After those: Issue 2 (hub accordion), Bug 17 (jumpy scroll). Historical sequencing notes remain under [Sequencing](#sequencing). --- @@ -81,25 +81,43 @@ Two distinct problems hiding behind one symptom ("clicking a directory hangs"): --- -## Bug 5 — Multi-attachment send produces no output +## Bug 5 — Multi-attachment send produces no output ✅ DONE 2026-07-19 -**Filed:** 2026-05-18 — not diagnosed. +**Filed:** 2026-05-18. **Status (2026-07-22):** 🔄 In flight — [PR #345](https://github.com/RonenMars/threadbase-mobile/pull/345) escapes spaces in composer `@path` payloads; root cause was shell/`@path` tokenization (not multi vs single). Pair with streamer [PR #241](https://github.com/RonenMars/threadbase-streamer/pull/241) (`sanitizeFilename`). Multi-select/upload already shipped earlier (`c70a498`). **Symptom:** Start a new session, send a message with 2 attachments — the UI never shows a response. -**Suspected cause:** Today's send-message path is built for a single attachment; the 2-attachment case either fails the send silently, succeeds server-side but doesn't deliver, or arrives but is rejected by a renderer assumption. Adjacent to the planned multi-file attachments feature (see [ROADMAP.md](./ROADMAP.md) Feature 3) — likely the same code paths. +**Root cause:** Filenames with spaces broke Claude Code's `@path` reference parsing. When uploading a file named "My Photo.jpg", the server saved it to a path like: +``` +/project/.threadbase-uploads/sessionId/1234-up_abc-My Photo.jpg +``` -**Diagnosis order when picked up:** -1. Inspect the network payload — does send-message ship 2 attachments at all? -2. Check streamer logs — did the turn get stored / did the assistant respond? -3. Check session WS stream — did the assistant turn arrive client-side? -4. Trace message-content reducer / renderer for any single-attachment assumption. +The mobile then sent: +``` +@/project/.threadbase-uploads/sessionId/1234-up_abc-My Photo.jpg what is this? +``` -**Files to start with (to verify):** -- Message composer / attachment picker -- Send-message handler in `hooks/` or `services/` -- streamer send-message endpoint -- Session WS stream subscriber +Claude Code's parser splits on whitespace, so it parsed this as: +- `@/project/.threadbase-uploads/sessionId/1234-up_abc-My` (truncated reference — file not found) +- `Photo.jpg what is this?` (plain text) + +The truncated reference pointed to a non-existent file, causing Claude to fail silently. + +**Fix (two-part):** + +1. **Streamer (`tb-streamer/src/uploads.ts`):** `sanitizeFilename()` now replaces spaces and other shell-problematic characters (`@ " ' \` $ \`) with underscores. New uploads will have safe filenames. + +2. **Mobile (`tb-mobile/hooks/useComposerState.ts`):** `buildPayload()` now escapes spaces in paths with backslashes (`\ `). This handles legacy files uploaded before the streamer fix. + +**Verification:** +1. Upload 2 images with spaces in their names (e.g., from Photos app) +2. Send a message asking about them +3. Confirm Claude responds about both images + +**Files changed:** +- `tb-streamer/src/uploads.ts` — sanitize spaces in filenames +- `tb-mobile/hooks/useComposerState.ts` — escape spaces in @path references +- `tb-mobile/__tests__/unit/hooks/useComposerState.test.ts` — tests for path escaping --- @@ -126,7 +144,7 @@ Two distinct problems hiding behind one symptom ("clicking a directory hangs"): ## Bug 7 — Quick Access strip: default-collapsed + tab reorder + hide when fully empty -**Filed:** 2026-05-22. +**Filed:** 2026-05-22. **Status (2026-07-22):** ✅ DONE — Favorites-only Quick Access (`c43e9a1`); Recents/Popular tabs removed. Three small UX tweaks reported together. They share a file (`components/quick-access/QuickAccessStrip.tsx`) and a store (`stores/quickAccess.ts`), so handle as one ticket. @@ -160,7 +178,7 @@ Three small UX tweaks reported together. They share a file (`components/quick-ac ## Bug 8 — Manage Favorites: duplicate top bar -**Filed:** 2026-05-22. **Note:** 2026-05-25 — the original 8b ("Add to favorites" empty-state CTA) was reclassified as a feature and moved to ROADMAP as [Feature 24](./ROADMAP.md#feature-24--manage-favorites-add-to-favorites-empty-state-cta). What remains below is the original 8a bugfix. +**Filed:** 2026-05-22. **Status (2026-07-22):** ✅ DONE — one native Stack header; no in-screen duplicate. **Note:** 2026-05-25 — the original 8b ("Add to favorites" empty-state CTA) was reclassified as a feature and moved to ROADMAP as [Feature 24](./ROADMAP.md#feature-24--manage-favorites-add-to-favorites-empty-state-cta). What remains below is the original 8a bugfix. **Remove the duplicate top bar.** - Screenshot shows two stacked top bars: the system Stack header (`< manage-favorites`) AND the screen's own custom header (`← Back Manage Favorites`). Both are rendering. @@ -178,7 +196,7 @@ Three small UX tweaks reported together. They share a file (`components/quick-ac ## Bug 9 — Quick Access: hide Edit pencil when strip is collapsed -**Filed:** 2026-05-22. +**Filed:** 2026-05-22. **Status (2026-07-22):** ✅ DONE — pencil/gear gated on `!stripCollapsed`. **Symptom:** When the Quick Access strip is collapsed (chips hidden, only the tab bar visible), the Edit-mode pencil icon still renders on the right side of the tab bar. It does nothing useful in that state — there are no chips to enter edit mode for — and it's visually cluttering an already-dense row. @@ -268,7 +286,7 @@ setShowScrollTop(scrollingUp && y > 100) ## Issue 1 — Post-intro: cached Hub list flashes, then re-paints with server data -**Filed:** 2026-05-22. +**Filed:** 2026-05-22. **Status (2026-07-22):** ✅ DONE — cached-data refresh indicator (`2a3e6d8` / #326). ### Symptom @@ -406,7 +424,7 @@ Quick scan of `FlatList` / `FlashList` / `SectionList` / inline `.map` over serv ## Bug 13 — New session: name modal flashes open then auto-closes before user can type -**Filed:** 2026-05-23 — not diagnosed. +**Filed:** 2026-05-23. **Status (2026-07-22):** ♻️ Replaced — create/exit name modals removed (`6c138c8`); rename remains. **Symptom:** From the browse screen, pick a path and tap **Start session**. The "Name this session?" modal (`NameSessionModal`, `mode="create"`) opens for a fraction of a second and then closes on its own. The user is navigated straight to `/session/[id]` without any chance to type a name or tap Skip / Start / Don't ask again. @@ -460,7 +478,7 @@ Quick scan of `FlatList` / `FlashList` / `SectionList` / inline `.map` over serv ## Bug 14 — After starting new session, file browser stays in stack and re-shows on exit -**Filed:** 2026-05-23 — not diagnosed. +**Filed:** 2026-05-23. **Status (2026-07-22):** ✅ DONE — `/session/new` dismiss-then-push (`0909404`). **Symptom:** Start a new session from `/browse` (pick a path, tap **Start session**, get into `/session/[id]`). Later, when the user exits the session (back gesture / Resume Session back navigation / etc.), instead of landing on the Hub (`/`), they land back on the file browser modal — which by then is a stale view of a directory they've already moved past. @@ -505,7 +523,7 @@ router.push(`/session/${id}?...`) ## Bug 15 — After new-session back, file browser is interaction-locked (only close works) -**Filed:** 2026-05-24 — not diagnosed. **Re-repro 2026-05-25** added top-left chevron + recent-directories details. +**Filed:** 2026-05-24. **Status (2026-07-22):** ✅ DONE — eliminated with Bug 14 navigation. **Re-repro 2026-05-25** added top-left chevron + recent-directories details. **Symptom:** Start a new session from `/browse` (pick a path, tap **Start session**). The PTY opens at `/session/[id]`. Tap **back**. The browse modal re-appears (the same surface flagged in [Bug 14](#bug-14--after-starting-new-session-file-browser-stays-in-stack-and-re-shows-on-exit)), but in this stale state **almost nothing inside the modal responds to taps** — directory rows, the recent-directories list, "Start session", and the up-directory affordance are all dead. The **top-left back chevron** is *visually* active but only walks the directory tree inside the modal; it never dismisses the modal. The only control that actually exits the modal is the **drag-down-to-close** gesture on the modal title bar. @@ -527,7 +545,7 @@ router.push(`/session/${id}?...`) ## Bug 16 — Back from never-typed-in new session leaves an empty session alive -**Filed:** 2026-05-24 — not diagnosed. +**Filed:** 2026-05-24. **Status (2026-07-22):** 🔄 In flight — [PR #346](https://github.com/RonenMars/threadbase-mobile/pull/346) stops unused fresh PTYs on back when `promptCount === 0`. **Symptom:** From `/browse`, pick a path and tap **Start session**. The PTY opens at `/session/[id]`. **Without typing anything**, tap **back**. The session is left running on the streamer — it now shows up in the Hub / Recents as a brand-new but empty session. The user clearly abandoned it (no prompt sent, no PTY input), so keeping it around is just noise. @@ -606,32 +624,21 @@ If all of the above are still empty at exit time, the session is a discard. --- -## Bug 18 — Maestro flow `server_drag_reorder.yaml.skip` crashes the app at the `swipe` step - -**Filed:** 2026-05-24. **Status:** **Partial.** A working flow was recreated as `e2e/server_drag_reorder.yaml` (commit `cd6d753`, `test(e2e): add server drag-reorder maestro flow`). The stale `e2e/server_drag_reorder.yaml.skip` has now been deleted (2026-06-30, E2E remediation). Remaining work: (1) verify `server_drag_reorder.yaml` passes locally against the mock fixture, (2) add it to the `maestro test ...` arglist alongside the other flows. (Step 3 — removing the `.skip` — is done.) - -**Symptom:** The flow runs `setup.yaml`, taps the filter-sort button, then the conditional `runFlow` enters the multi-server branch even on a single-server fixture. The first `swipe` against `id: "drag-handle-srv_a"` causes the app to crash within ~4 s. Maestro log: *"App crashed or stopped while executing flow, please check diagnostic logs: ~/Library/Logs/DiagnosticReports directory"*. +## Bug 18 — Maestro flow `server_drag_reorder` — swipe crash / suite wiring -**Root-cause hypothesis (unverified):** - -1. **Most likely — branch-guard misfires.** The `when: visible: { id: "server-order-toggle" }` gate is supposed to skip the multi-server commands when only one server is paired (the default after `setup.yaml`). Maestro 2.x may evaluate `visible` more leniently than expected (or the toggle has different `testID` resolution on single-server now), so the `runFlow` enters the branch and tries to drag a `drag-handle-srv_a` row that doesn't exist. The crash is whatever React Native does when a missing-element `swipe` runs on `DraggableFlatList`. -2. **Less likely — `swipe` API misuse.** The flow uses Maestro 2.x's `swipe: { direction: DOWN, from: { id: ... } }` form (replacing the deprecated `dragAndDrop: { from, to }`). The 2.0.10 docs say this works, but it's possible the from-element resolution is brittle on RN. - -**Steps to fix:** - -1. Add a `MOCK_SERVERS=srv_a,srv_b` env var to `e2e/mock-server.js` and seed `setup.yaml` to pair both, so the multi-server branch has the rows it expects. -2. Add explicit `assertVisible: { id: "drag-handle-srv_a" }` *before* the `swipe`, so the flow fails the assertion (clean Maestro failure) instead of crashing the app (opaque "app stopped" error). -3. Verify Maestro 2.0.10's `swipe` syntax against [the official 2.x reference](https://maestro.mobile.dev/api-reference/commands/swipe) — confirm `from: { id }` is the right invocation. -4. If single-server should still be tested, fall through to the `notVisible` branch and assert the toggle is hidden (already in the flow). +**Filed:** 2026-05-24. **Status:** ✅ **DONE** (2026-07-22). Wired into `npm run test:e2e:mock`. -**Re-enable:** `git mv e2e/server_drag_reorder.yaml.skip e2e/server_drag_reorder.yaml` and add the file back to the `maestro test` arglist in `package.json` → `test:e2e:mock`. +**Resolution:** +1. Flow lives at `e2e/server_drag_reorder.yaml` (`.skip` deleted earlier). +2. Added to the `maestro test …` arglist in `package.json` → `test:e2e:mock`. +3. Removed the Maestro `swipe` on `drag-handle-*` — that step crashed the app on `NestableDraggableFlatList`. Default mock path (one server after `setup.yaml`) asserts `server-order-toggle` is hidden (`activeServerIds.length >= 2` gate). Multi-server branch only toggles edit-order on/off + screenshot; real reorder is covered by `__tests__/integration` for `DisplayedServersList`. -**Files likely involved:** +**Original symptom (kept for search):** swipe against `id: "drag-handle-srv_a"` crashed within ~4 s (*"App crashed or stopped while executing flow"*). -- `e2e/server_drag_reorder.yaml` — the flow (the `.skip` variant was deleted) -- `e2e/setup.yaml` — server-pairing seed -- `e2e/mock-server.js` — multi-server fixture support -- `components/servers/DisplayedServersList.tsx` — drag-handle testID emission +**Files:** +- `e2e/server_drag_reorder.yaml` +- `package.json` (`test:e2e:mock`) +- `components/servers/DisplayedServersList.tsx` — drag-handle testIDs --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e01d833a..cbd4be25 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,9 +14,9 @@ Earlier-stage, not-yet-prioritized ideas live in [IDEAS.md](./IDEAS.md). When an |---|---| | Feature 1 — Tree directory view: pre-fill new-session path with current directory | ✅ Shipped 2026-05-24 (PR #11, commit 9835ecf) | | Feature 2 — Move Export button from Historical session bottom bar into the info shelf | ✅ Shipped 2026-05-24 (PR #11, commit 9835ecf) | -| Feature 3 — Attach multiple files to a single message | Planned (larger — split into its own plan when picked up) | +| Feature 3 — Attach multiple files to a single message | 🟡 Partial — multi-select/upload shipped; Bug 5 `@path` fix in flight (#345 / streamer #241) | | Feature 4 — Auto-deploy to App Store + Google Play | Planned (CI/release infra) | -| Feature 5 — Polish the onboarding flow | Planned (scope to be defined) | +| Feature 5 — Polish the onboarding flow | Planned (scope to be defined) — **next action** for OSS invite polish | | Feature 6 — Cross-session search with hit context + "open in session" | Planned (orchestration) | | Feature 7 — Workspace tagging across sessions / conversations / projects | Planned (orchestration) | | Feature 8 — Saved views: persisted filter + sort + tag combos as named tabs | Planned (orchestration) | @@ -28,7 +28,7 @@ Earlier-stage, not-yet-prioritized ideas live in [IDEAS.md](./IDEAS.md). When an | Feature 14 — Voice prompts via on-device Whisper | Planned (mobile-native) | | Feature 15 — Scheduled prompts ("send tomorrow at 9am") | Planned (async-collab) | | Feature 16 — Sync mode: JSONL-sourced bubbles + native prompt forms | Planned (mobile-native, cross-repo) | -| Feature 17 — Expand Maestro E2E coverage to high-value flows | Planned (CI/quality) | +| Feature 17 — Expand Maestro E2E coverage to high-value flows | Planned (CI/quality) — **next action**; release suite still ungreen | | Feature 18 — Upgrade to Expo SDK 56 | ✅ Shipped 2026-05-25 (TestFlight build 104; React Compiler re-enabled in 106) | | Feature 19 — Queue-while-thinking: recolor send button as "add to queue" during a turn, auto-send when idle | Planned (composer UX) | | Feature 20 — Visual regression gate on Maestro screenshots | Planned (CI/quality, follow-on to Feature 17) | @@ -44,10 +44,10 @@ Earlier-stage, not-yet-prioritized ideas live in [IDEAS.md](./IDEAS.md). When an | Feature 30 — Build-time warning cleanup (ship-121 follow-ups) | Planned (platform/noise, low-priority) | | Feature 32 — Handle batched `conversation_events` WS event | Planned (perf, low-priority) | | Feature 34 — Structured prompt cards for Codex sessions | Planned (cross-repo, streamer-side) | -| Feature 35 — Decide the crash-reporting consent model (auto-init vs. explicit-only) | Planned (privacy/product decision) | -| Feature 36 — Validate the privacy checklist [Privacy follow-up checklist](./privacy-policy/privacy-follow-up-checklist.md) and review also: [app recommendations](./privacy-policy/threadbase-crash-reporting-ux-recommendation.md) | Planned (privacy/product decision) | +| Feature 35 — Decide the crash-reporting consent model (auto-init vs. explicit-only) | 🔄 In flight — option (a) in [PR #343](https://github.com/RonenMars/threadbase-mobile/pull/343) | +| Feature 36 — Validate the privacy checklist [Privacy follow-up checklist](./privacy-policy/privacy-follow-up-checklist.md) and review also: [app recommendations](./privacy-policy/threadbase-crash-reporting-ux-recommendation.md) | 🟡 Partial — code path in #343; store/legal/on-device evidence still human-only | -**Suggested order for the remaining originals:** **Feature 5** (onboarding polish — needs a scoping pass first) → **Feature 4** (auto-deploy — pick up once releases are happening regularly enough to justify CI investment) → **Feature 3** (multi-file attachments, larger; diagnose [Bug 5](./BACKLOG.md#bug-5--multi-attachment-send-produces-no-output) first — the two may collapse). Features 1 and 2 shipped in PR #11 (2026-05-24); both have full entries preserved in [Shipped](#shipped) for traceability. +**Suggested order for the remaining originals (2026-07-22):** Merge **Feature 35/36** (#343) + **Bug 5** (#345). Then **typecheck green** → **Feature 17 / Maestro suite** → **Feature 5** (onboarding polish — needs a scoping pass). **Feature 4** (auto-deploy) after releases are regular. Feature 3 multi-select largely shipped; finish via Bug 5 PRs. Features 1 and 2 shipped in PR #11 (2026-05-24); both have full entries preserved in [Shipped](#shipped) for traceability. **Suggested order for the orchestration cluster (6–15):** **Feature 13 Mission Control** (biggest daily-orchestration unlock, no native modules, reuses existing infra) → **Feature 6 Cross-session search** (already half-shipped at `hooks/useConversations.ts:431`; finishing it changes what the app is for) → **Feature 12 Live Activities** (highest mobile-native ceiling) → **Feature 15 Scheduled prompts** (strong async-teammate unlock if streamer cron is cheap) → **Feature 7 Tagging** → **Feature 8 Saved views** (builds on 7) → **Feature 10 Snippets** → **Feature 11 Workspace sync** (only after 7/8/10 exist and are worth syncing) → **Feature 14 Voice** (great but possibly overkill before #6 and #13 settle the workflow) → **Feature 9 Split view** (fun but iPad-coded; deprioritize if primary device is iPhone). @@ -132,7 +132,8 @@ Today's setup (per [README](../README.md#shipping) + the local ship scripts): ### Feature 5 — Polish the onboarding flow -**Filed:** 2026-05-22. +**Filed:** 2026-05-22. **Status (2026-07-22):** 🔄 In flight on `feat/onboarding-polish-top5` — audit complete; shipping top XS/S wins (Skip/swipe empty-Hub guard, QR advance without re-handshake, back-to-choose, Mac→computer copy + i18n QR steps, default port 8766). Still open: manual `tb pair` token exchange in `useTBPair`, NotificationsStep re-wire. + **Goal:** Refine the first-launch onboarding experience. Today's flow (`app/onboarding.tsx` + `components/onboarding/{OnboardingShell,OnboardingNavigator}.tsx` + `components/onboarding/steps/*`) gets users from cold install to a paired server, but the rough edges haven't been cataloged or prioritized. @@ -1089,7 +1090,35 @@ Today's setup (per [README](../README.md#shipping) + the local ship scripts): ### Feature 35 — Decide the crash-reporting consent model (auto-init vs. explicit-only) -**Filed:** 2026-07-13. +**Filed:** 2026-07-13. **Implemented:** 2026-07-18 — decision **(a)**. + +**Decision:** Option **(a)** — explicit user-initiated actions (one-shot crash report AND Help & Feedback) both self-init Sentry for the duration of the submit, then tear down if standing consent is off. Standing/automatic reporting remains gated by the Settings toggle (off by default). + +**What was implemented:** + +1. **`services/sentry.ts`** — `submitFeedbackViaSentry` now self-inits like `reportOneShot`: when the user taps Send, it initializes Sentry for that one submission even if the standing toggle is off, then tears down afterward so the "reporting is off" state is genuinely unaffected. +2. **`app/help-feedback.tsx`** — success view now shows a brief note indicating which delivery method was used ("Sent via Sentry crash-reporting service" or "Sent via email"), addressing the "user expectation mismatch" concern by making the delivery path visible. +3. **`locales/en/feedback.json`** — added translation keys for the delivery-path notes. +4. **`docs/privacy-policy/proposed-privacy-policy.md`** — updated to clearly describe that feedback submissions may use Sentry even when the standing toggle is off, matching the one-shot crash report behavior. +5. **`docs/store-privacy-checklist.md`** — updated cross-cutting reminder to describe both crash report and feedback as user-initiated Sentry paths independent of the standing toggle. +6. **`docs/store-console-wording.md`** — updated App Store Connect and Google Play Console draft wording to include feedback submissions alongside crash reports as cases where Sentry may be used. + +**Consent model summary:** + +| Path | Standing toggle required? | How it works | +|------|--------------------------|--------------| +| Automatic crash reporting | Yes | Only initializes if toggle is on | +| Manual crash report ("Report this crash") | No | Self-inits for one send, tears down after | +| Help & Feedback submission | No | Self-inits for one send, tears down after | + +All three paths still require a DSN and an environment that permits reporting — those gates are unconditional. The only thing the standing toggle controls is whether Sentry stays running in the background to catch crashes automatically. + +**Not implemented (follow-up):** The full first-crash recovery screen from `docs/privacy-policy/threadbase-crash-reporting-ux-recommendation.md` (showing what's included/excluded, offering "Always send" as a separate opt-in) — this can be a future enhancement but is not required for the consent-model consistency fix. + +--- + +
+Original problem statement (archived) **Goal:** Resolve a design gap surfaced while testing the Sentry feedback flow (`feat/sentry-crash-reporting`, PR #303): two of the app's Sentry entry points behave inconsistently with each other around the "Share anonymous reports" toggle, and the inconsistency itself raises a real product question — should *any* Sentry path work without the standing toggle being on? @@ -1113,7 +1142,7 @@ Today there are three ways data can reach Sentry, and they don't agree on what " - **Consistency reduces support/debug burden.** Right now, "does this feature work?" has a different answer depending on which Sentry entry point is asked — as seen in this session's own back-and-forth diagnosing why feedback attachments/diagnostics weren't appearing. A single consistent rule (whichever direction) removes an entire class of "why didn't this work" investigation. - **Silent fallback vs. explicit failure.** Path 3's current silent fallback to email is defensible (the user still gets their feedback delivered), but it means the "Include technical diagnostics" and screenshot-attachment work only reliably reaches Sentry when the toggle happens to already be on — which may be rare for typical users who never visit Settings. -**Not yet decided — do not implement without picking (a) or (b) first**, since they're mutually exclusive framings of what "the toggle" means app-wide. +**Status (2026-07-22):** Decision **(a)** chosen — explicit feedback self-inits Sentry like one-shot crash. Implementation in flight: [PR #343](https://github.com/RonenMars/threadbase-mobile/pull/343). Update this entry to ✅ when #343 merges. **Files likely involved:** - `services/sentry.ts` — `submitFeedbackViaSentry`, `reportOneShot`, `initCrashReporting` diff --git a/docs/a11y-theme-perf.md b/docs/a11y-theme-perf.md new file mode 100644 index 00000000..9038cc60 --- /dev/null +++ b/docs/a11y-theme-perf.md @@ -0,0 +1,26 @@ +# Accessibility, theme, and performance (U12) + +Audit notes for the hardening landed with `feat/a11y-theme-perf`. + +## Done in this pass + +| Area | Change | +|------|--------| +| Reduce Motion | `useReducedMotion` gates enter animations on `MessageItem` and `SessionCard` | +| Touch targets | Terminal jump/copy controls and DiffViewer copy use `MIN_TOUCH_TARGET` (44) | +| Dynamic Type | Monospace terminal/diff text capped via `MAX_FONT_SIZE_MULTIPLIER_MONO` | +| Screen readers | Terminal lines expose `accessibilityLabel` (`terminal:a11y.line`); DiffViewer container labeled by filename | +| Review entry | Session header Review control already ships with an accessibility label | + +## Remaining / follow-ups + +- Full accordion Virtualization audit across hub project trees under pathological fan-out +- TalkBack-specific Maestro flows on Android +- Contrast tokens audit for every theme variant (esp. glass + light) +- Long Hebrew/Arabic truncation passes on every settings row + +## Manual checks + +1. Enable Reduce Motion → new session cards and live chat rows should appear without FadeInDown. +2. Bump Dynamic Type to accessibility sizes → terminal remains readable without horizontal blowouts. +3. VoiceOver on a live terminal → each line announces “Line N: …”. diff --git a/docs/ci-significant-paths.md b/docs/ci-significant-paths.md index e91bed33..3ffe11ba 100644 --- a/docs/ci-significant-paths.md +++ b/docs/ci-significant-paths.md @@ -68,6 +68,7 @@ Native / deploy pipeline: - `fastlane/` — iOS TestFlight lanes (invoked by `ship-ios.sh`) - `eas.json` — EAS build config - `Gemfile` — Ruby deps for Fastlane + CocoaPods +- `patches/` — patch-package fixes applied to node_modules on install (e.g. iOS build fixes for vendored native/Swift sources); a patch can go stale or start/stop applying without any package.json change ## Does NOT affect (excluded from the trigger list) diff --git a/docs/e2e-remaining-work.md b/docs/e2e-remaining-work.md index d187dee3..ea0be9bb 100644 --- a/docs/e2e-remaining-work.md +++ b/docs/e2e-remaining-work.md @@ -1,14 +1,15 @@ # E2E (Maestro) Mock Suite — Remaining Work Status of the `npm run test:e2e:mock` flow suite after the fixes in this branch. -The suite runs 11 flows against `e2e/mock-server.js` on an **iOS 17 simulator** +The suite runs 12 flows against `e2e/mock-server.js` on an **iOS 17 simulator** (Maestro 2.0.10 cannot drive iOS 26 reliably — the preflight in `e2e/check-sim.js` rejects it). ## Passing - `launch` -- `browse` — _regressed to a crash in the last full run; see below_ +- `browse` — see §5 for the earlier "App crashed" misattribution; FilterSortSheet + open is healthy when setup reaches the hub (successful Jul 12 runs + screenshot) - `bug6_bottom_bar_inset` - `codex_parity` - `settings_qr_scanner` @@ -18,6 +19,7 @@ rejects it). These have had their root cause addressed but were not yet confirmed green after the latest edits: +- `server_drag_reorder` — added to `test:e2e:mock`; swipe removed (Bug 18); default path asserts single-server lock-toggle gate - `feat2_export_in_info_shelf` — deep link switched `launchApp {url}` → `openLink`; route assertion switched from the brittle `"Resume Session"` text (the button is now `▶ Resume Session` inside an `Animated.Text`) to the `conversation-bottom-bar` @@ -28,10 +30,20 @@ the latest edits: - `voice_dictation` — stale testIDs `message-input` / `message-input-mic` replaced with `chat-message-input` / `chat-mic-button`. **Still blocked**, see below. +## Bug 18 / `server_drag_reorder` (done) + +Included in `test:e2e:mock`. Flow no longer swipes drag handles (known NestableDraggableFlatList crash). +Mock default = one paired server → asserts `server-order-toggle` hidden. Remaining risk: multi-server +edit-order path is smoke-only (toggle + screenshot); true reorder still lives in integration tests. +A full green re-run of the mock suite has not been confirmed in this change. + ## Still to do ### 1. `voice_dictation` — speech-recognition permission prerequisite +**Status (2026-07-22):** `e2e/ensure-release-build.js` now grants `speech-recognition` + `microphone` via `simctl privacy` before the suite runs. Re-run to confirm green. + + `chat-mic-button` only renders when `micGranted === true` (`components/conversation/ChatComposer.tsx:147`). `micGranted` comes from `ExpoSpeechRecognitionModule.getPermissionsAsync()` @@ -49,13 +61,10 @@ Options: ### 2. `feat1_tree_drill_new_session` — hub renders a flat list, not a tree -The flow expects `tree-row-/home/user/my-project` and `drill-cwd-...`, but the hub -now renders a flat "LIVE" session list by default (the flow comment's "TreeView is -the default layout" is stale). Confirm the current default `sessionsLayout` and -either (a) toggle tree view on in the flow before drilling, or (b) update the flow -to the current hub interaction. Tree testIDs still exist in -`components/sessions/tree/TreeSessionsList.tsx` (`tree-row-…`, `conversation-row-…`), -so tree view is reachable — it is just not the default. +**Fixed in this branch:** default `sessionsLayout` is `classic` +(`stores/settings.ts`). The flow now opens `FilterSortSheet`, taps +`layout-option-tree`, closes via `filter-sort-close-btn`, then drills +`tree-row-/home/user/my-project`. Re-run to confirm green. ### 3. `05_chat_flow` — no live session card / composer @@ -77,13 +86,23 @@ query "wombat" and that `/api/conversations/conv-search-anchor` + its `search-target` route serve the fixture the flow asserts (`"2 of 2"`, `"wombat timeout"`). -### 5. `browse` — app crash - -Last full run: "App crashed or stopped while executing flow." The flow taps -`filter-sort-button` → expects `filter-sort-sheet` -(`components/servers/FilterSortSheet.tsx`). Read the newest Threadbase crash in -`~/Library/Logs/DiagnosticReports` to get the faulting frame before deciding -whether it is a flow issue or a real app bug in the filter/sort sheet. +### 5. `browse` — "App crashed" (misattributed to FilterSortSheet) + +**Diagnosis (artifact-backed):** Maestro's "App crashed or stopped while +executing flow" on browse did **not** fail at `filter-sort-button` / +`filter-sort-sheet`. Successful runs (`2026-07-12_195509`, `200450`) completed +the full tap → assert → screenshot path. Failed runs (`2026-07-12_210105`, +baseline `2026-07-16_044809`) died inside `setup.yaml` — either stuck on the +splash / evaluating `onboarding-welcome-cta`, or `hideKeyboard` failing on the +pair URL field. No `*Threadbase*` crash report appeared in +`~/Library/Logs/DiagnosticReports`; the checked-in `crash-log.txt` is a +**SpringBoard / XCTAutomationSupport** SIGSEGV (Maestro XCUITest driver), not +an app JS/native frame in FilterSortSheet. + +**Mitigations in this branch:** browse.yaml uses `extendedWaitUntil` for the +sheet + asserts `fab-new-session`; FilterSortSheet adds layout/close testIDs and +aligns reset/default layout with `classic`. Remaining flake is setup / +XCUITest-driver infrastructure (see Environment gotchas), not a sheet open bug. ## Environment gotchas discovered diff --git a/docs/integration-merge-report-2026-07-22.md b/docs/integration-merge-report-2026-07-22.md new file mode 100644 index 00000000..b1a22c32 --- /dev/null +++ b/docs/integration-merge-report-2026-07-22.md @@ -0,0 +1,230 @@ +# Integration merge report — `integration-dev/v1.0.0-2026-07-22` + +**This document is the authoritative merge flow for the currently-open PRs.** +When these branches are merged individually, follow the order and the per-conflict resolutions recorded here rather than re-deriving them. + +It is maintained as a **run log**: each snapshot cut appends a numbered run with its merges, conflicts and resolutions, so the history of what was tried and what it cost stays visible. + +--- + +## Run 2 — 2026-07-22, cut from `origin/main` @ `73d9ae5` + +Supersedes run 1. All **19** open PRs merged with `--no-ff`; **#291** (`typescript 6.0.3 → 7.0.2`) excluded by request. +24 merge commits for 19 PRs — #355 is based on the 2026-07-20 snapshot, so merging it replays that snapshot's 5 merge commits. + +**`origin/main` is fully contained** up to `3219d6f`, past the `73d9ae5` this was cut from. +`main` advanced by two CI commits (`54f6f43`, `3219d6f`) mid-run; they arrived transitively when the #372 docs branch — cut from the newer `main` — was merged in, so no separate `main` merge was needed. +`app.json` carries iOS build `166`, matching `main`. + +Immutable ref: the `test-dev/v1.0.0-…-2026-07-22` tag cut at this branch's tip. Prefer it over the branch as a `deploy_ref`. + +### Merge log + +Dependency order first, then chronological. `ok` = auto-merged, no conflict. + +| # | Commit | PR | Branch | Result | +|---|--------|----|--------|--------| +| 1 | `717b857` | #339 | `feat/cache-integrity-alert` | ok | +| 2 | `68a5a60` | #341 | `feat/cache-warmup-status` | ok — **conflict from run 1 is gone**, branch was rebased onto #339 since | +| 3 | `684af98` | #343 | `feat/crash-consent-model` | ok | +| 4 | `8041b30` | #345 | `fix/multi-attachment-send` | ok | +| 5 | `50d45d4` | #346 | `fix/abandoned-empty-sessions` | ok | +| 6 | `bd0402a` | #347 | `docs/pre-release-status-2026-07-19` | ok | +| 7 | `0d2b163` | #353 | `dependabot/…/npm_and_yarn-f53f33db58` | ok | +| 8 | `c86b4c2` | #354 | `feat/live-external-sessions` | **conflict A** | +| 9 | `8928ae5` | #355 | `feat/live-external-sessions-integration` | **conflict B** | +| 10 | `9dc57b8` | #356 | `chore/i18n-unused-keys-validation` | ok | +| 11 | `d942e52` | #357 | `fix/servers-remove-dialog-i18n` | **conflict C** | +| 12 | `2719288` | #358 | `docs/pre-release-status-sync-2026-07-22` | **conflict D** | +| 13 | `8cf5805` | #359 | `fix/e2e-grant-speech-recognition` | ok | +| 14 | `30a6e00` | #360 | `feat/onboarding-polish-top5` | **conflict E** + **silent conflict F** | +| 15 | `b94acfb` | #361 | `fix/e2e-browse-and-feat1` | ok | +| 16 | `bdf8e47` | #362 | `fix/onboarding-pair-token-exchange` | **conflict G** (new) | +| 17 | `c2af06b` | #363 | `fix/e2e-drag-reorder-in-suite` | ok | +| 18 | `bd7f90a` | #364 | `feat/onboarding-notifications-step` | **conflict H** (new) | +| 19 | `c875eb3` | #368 | `ci/i18n-parity-gate` | **conflict I** (new) | + +### Conflicts and resolutions + +**A — #354 × #341, `services/api-client.ts`.** +Both rewrote the same `!response.ok` block: #341 added `warmupState`, #354 hoisted `errBody` and added a 409 `ConversationBusyError` branch. +→ Took the file from #355's tip (`0fb0d42`), which already contains exactly this union. +Precondition re-verified this run: the pre-merge `api-client.ts` was byte-identical to #355's base, so the union applies unchanged. + +**B — #355 × everything before it, `app.json` + `locales/{ar,he,ru}/conversation.json`.** +New this run; run 1 conflicted on `CacheAlertModal.test.tsx` instead. +`app.json`: #355's base carries `buildNumber` 165, current `main` is 166. +`conversation.json`: #354 now carries the ar/he/ru `takeOver` translations; #355, based on the older snapshot, does not. +→ Took **ours** for all four — newer build number, and the translations must not be reverted. + +**C — #357 × #356, `locales/{ar,en,he,ru}/servers.json`.** +#356 pruned `dialog.removeMessage` and `dialog.removeDismiss` as dead; #357 adds code using `removeMessage`. +→ Union **by actual usage**: restored `removeMessage` (referenced by `ServerListCard.tsx` and `ServersStatusModal.tsx`), left `removeDismiss` pruned — re-checked this run, still unreferenced. + +**D — #358 × #347, `docs/BACKLOG.md` (4 hunks), `docs/ROADMAP.md` (2 hunks).** +→ Took #358; the 2026-07-22 sync supersedes the 2026-07-19 one. +One ROADMAP hunk needs hand-repair: #343 wraps that section in `
`, so taking #358's text alone orphans the closing `
`. Re-added; tag count verified balanced. + +**E — #360 × #356, `locales/{ar,en,he,ru}/onboarding.json`.** +→ Took #360's reworded `manualSectionLabel`; left `connect.footnote` pruned (unreferenced). + +**F — #360 × #356, the silent one (no conflict marker).** +#356 deletes `connect.step1` / `step2`, correct against `main`; #360 adds the *usage* (`ConnectStep.tsx`) but inherits the keys from `main` as context lines, so git applies the deletion cleanly. +→ Restored both keys in all 4 locales, byte-identical to `main`'s originals. +**Caught by Type check only** — see the hazard section. + +**G — #362 × #360, `ConnectStep.tsx` + 4 onboarding locales.** *(new this run)* +Both rework the manual-pairing step. +`ConnectStep.tsx`: the conflicting import line on #362's side is a strict superset (`splitUrl`, `classifyPairCredential`). +Locales: #362 rewords `manualSectionPasteHint` and adds `manualServerUrl`. +→ Took #362 for every hunk, **except** `manualServerUrl`, which was dropped — see the finding below. + +**H — #364 × #360, `components/onboarding/OnboardingNavigator.tsx`.** *(new this run)* +Comment-only. Each PR documents a different aspect of the same `onSkip`, and both remain true. +→ Kept both comment lines. No code difference. + +**I — #368 × #363, `package.json`.** *(new this run)* +Adjacent lines: #363 adds `e2e/server_drag_reorder.yaml` to `test:e2e:mock`, #368 inserts `test:i18n` directly above it. +→ Union: kept both. + +### Findings raised by this run + +**#362 introduced a dead locale key — fixed upstream.** `connect.manualServerUrl` was added in all 4 locales but never referenced, verified on #362's own branch and not only in the merge. +It was dropped during conflict G to keep the snapshot's dead-key gate green, then fixed at source on #362 itself (`4c6a275`, `fix(i18n): drop unused manualServerUrl onboarding key`). +#362's new head was merged back in (`ancestry-only — the tree was already identical`), so the snapshot still contains every open PR at its current head. +Without this, `i18n-unused-keys` would have flagged the key once #356 and #362 were both on `main`. + +**#362 did not type-check — fixed upstream.** `__tests__/unit/hooks/useTBPair.test.ts` used `global.__DEV__`, which React Native declares as a bare `const`, not a property of `globalThis` — 3 × `TS2339`. +A `declare global { var __DEV__ }` is not an option: it collides with RN's own `const __DEV__` declaration. +Fixed on #362 with a test-local typed alias (`ec5260f`, `fix(types): type the __DEV__ global alias in useTBPair tests`). + +**The `SessionScreen` failures were #346's, not #355's — fixed upstream.** +`useNavigation` was introduced by **#346** (`app/session/[id].tsx`, the Bug-16 `beforeRemove` listener), not #355; #355 only carries it because its base includes #346. +#346's own CI was already red on Integration tests, which confirms the attribution. + +Two distinct defects were stacked in those suites, the second only visible once the first was fixed: + +1. **`useNavigation` missing from the mocks.** The six local `jest.mock('expo-router', …)` factories replace the module wholesale, dropping the `useNavigation` that `jest.setup.js` provides — and the global stub returned only `setOptions`, with no `addListener` for the `beforeRemove` subscription. +2. **`stopSession` missing from `useSessionActions` mocks.** #346 added `stopSession` to the screen's destructure; four suites' mocks never provided it, so `stopSession.mutate` was undefined. + +Fixed on #346 (`b84f18c`, `test(session): mock navigation and stopSession for the session screen`) — `addListener` added to the global stub, `useNavigation` added to six local factories, `stopSession` added to the four mocks that lacked it. + +**`SessionScreen.externalGate` was a genuine cross-PR interaction.** The suite is new in #354 and its local `expo-router` mock omits `useNavigation`; the screen only calls `useNavigation` once #346 is present. Neither PR is wrong alone — the failure exists only in the combination. +Fixed on #354 (`25c83b6`, `test(session): mock useNavigation in the external-gate suite`), where the file lives; harmless there on its own. + +**#341's run-1 conflict disappeared.** Its branch was rebased onto #339 in the interim, so the 6-file add/add conflict recorded in run 1 no longer occurs. Recorded resolutions are perishable — re-verify preconditions rather than applying blind. + +### Verification + +Measured at the tip on the same machine, against an `origin/main` baseline. + +| Check | Result | +|---|---| +| `npm run test:i18n` | **3/3 suites pass** (`i18n`, `i18n-completeness`, `i18n-unused-keys`) | +| `npm run typecheck` | **clean** | +| `npx jest --ci` | **121/121 suites pass**, 1147 tests passed, 1 skipped, **0 failed** | + +**The snapshot is fully green.** Every failure found during this run was traced to the PR that caused it and fixed at source, then merged back in: + +| Was failing | Root cause | Fixed on | +|---|---|---| +| 6 × `SessionScreen.*` | #346 — `useNavigation` / `addListener` missing from mocks, then `stopSession` missing | #346 `b84f18c` | +| `SessionScreen.externalGate` | #354 × #346 interaction — suite's mock omits `useNavigation` | #354 `25c83b6` | +| `typecheck` (3 × TS2339) | #362 — `global.__DEV__` | #362 `ec5260f` | +| `i18n-unused-keys` (would have) | #362 — dead `manualServerUrl` key | #362 `4c6a275` | + +Two earlier observations did not reproduce in the final run and needed no fix: `e2e/feedback-flow` (a 5s timeout that also fails on `main` on this machine — Windows perf) and `unit/components/servers/CacheAlertModal` (flaky under full-suite load, 12/12 in isolation). Both pass in the green run above. + +Note on measuring: several `SessionScreen` suites are heavy enough that parallel jest workers on this machine produce spurious failures. Use `--runInBand` when verifying them, and re-check any single-suite failure in isolation before treating it as real. + +--- + +## Standing hazard — #356 × #360, carries beyond any snapshot + +> When #356 merges to `main` and you rebase #360 onto it, git will apply #356's deletion into #360's base and the keys vanish again, exactly as they did in the integration merge. +> There is no way to express the fix on #360 today, since the keys are already present. +> Options: merge #360 before #356, or re-add the keys to #360 as part of the rebase. + +### Ordering does not solve it — verified + +`git merge-tree` was run on #356 and #360 in **both** directions. +Both produce identical outcomes: `connect.step1` absent from the merged `locales/en/onboarding.json`, and both report exactly **one** conflict marker — the `manualSectionLabel` / `footnote` hunk, *not* the step keys. + +So "merge #360 before #356" does **not** help. +Worse, the merge hands you a conflict *in the very file that is silently losing keys*: you resolve the visible hunk, the file looks handled, and the deletion rides along unnoticed. That is exactly how it slipped through in run 1. + +The same trap applies to conflict C (#357's `removeMessage`) and now conflict G (#362's locale keys), though those surface as real conflicts rather than silently. + +### What does solve it + +**Rebase, then re-verify — and merge only on post-rebase green.** +This is already the repo's stated merge rule, and it is sufficient, because **Type check catches this class of bug**: `lib/i18n.types.ts` types `t()` against the locale JSON, so a referenced-but-missing key is a `TS2345` compile error. + +The hazard only materialises if a PR is squash-merged on a **stale** green CI run from before the rebase. +For whichever of #356 / #360 goes second: + +1. `git fetch origin && git rebase origin/main` +2. Push with `--force-with-lease` and wait for a **fresh** CI run +3. If Type check goes red on `connect.step*`, re-add the keys to the locale files as part of the rebase — do not merge red + +GitHub Merge Queue automates precisely this guarantee and is the durable fix if PR volume grows; it is already flagged as the adjacent pattern in `docs/research/2026-07-21-pre-merge-integration-build-strategy.md`. + +### The gap Type check cannot cover — closed by PR #368 + +CI ran only `test:unit` (`__tests__/unit`) and `test:integration` (`__tests__/integration`). +`i18n-completeness` and #356's `i18n-unused-keys` sit at the root of `__tests__/` and therefore never ran on a PR. +That is why #343 and #354 stayed green while adding English-only keys — invisible to CI, and invisible to Type check too (a *missing translation* is not a type error; only a *missing key* is). + +**PR #368** adds a `test:i18n` script and an `i18n` CI job alongside Lint and Type check. +Verified by deleting a key from `locales/ar/common.json` and confirming the job goes red. + +**After #368 merges, add `i18n` to the required status checks in branch protection**, or it runs without being able to block a merge. + +--- + +## Missing translations — audited and fixed + +All open PRs were audited against their own trees; `main` has zero gaps, so every gap was introduced by its PR. +Two were responsible, both fixed on the branch that introduced the keys: + +| PR | Namespace | Keys | Fix | +|---|---|---|---| +| #343 | `feedback.json` | `success.viaSentry`, `success.viaEmail` | `393b9ca` — ar/he/ru | +| #354 | `conversation.json` | `resume.takeOver`, `resume.takeOverFailed` | `5b26bf7` — ar/he/ru | + +Translations reuse terminology already in the files rather than inventing wording — `الإبلاغ عن الأعطال` / `דיווח קריסות` / `отчётов о сбоях` for crash reporting, and the existing `terminal.json` `overtake` wording (`الاستحواذ` / `השתלט` / `Перехватить`) for take-over. + +#355 still reports the same gaps, but it is not a third offender — it is #354 rebased onto the older snapshot, so it inherits both sets and clears once rebased. + +--- + +## Running the suite in a snapshot worktree + +Both gotchas below are now documented permanently in [`docs/troubleshooting.md`](./troubleshooting.md) → "Jest test suites" and summarised in `CLAUDE.md` (PR #372), so they survive this snapshot's deletion. Repeated here because they bite on every snapshot run. + +**Create the worktree outside `.claude/`.** A worktree under `.claude/` is excluded by `testPathIgnorePatterns`, so `npx jest` finds **0 tests** and `npm run test:i18n` looks broken when it is not. +This run used `worktrees/merge-prs-v2`. To run in place anyway, pass `--testPathIgnorePatterns "/node_modules/"` — but note that also re-enables `__tests__/unit/scripts/`, which the main config excludes deliberately and which fails on Windows. + +Each worktree needs its own `npm ci` (~3 min, 1292 packages). + +**Verify heavy suites serially, and confirm every failure in isolation.** + +```bash +npx jest --ci --runInBand --testPathPattern "SessionScreen" +``` + +Several `SessionScreen.*` suites are heavy enough that parallel jest workers on this machine produce spurious failures — but a load artifact and a real defect look identical in batch output, so the isolation re-run is what distinguishes them. Passes alone → artifact. Fails alone → real. + +This run proved both directions: four genuinely broken suites were nearly dismissed as flakes, while `feedback-flow` and `CacheAlertModal` really were load artifacts and needed no fix. + +--- + +## Run 1 — 2026-07-22, cut from `origin/main` @ `b7d8bda` (superseded) + +14 PRs merged (all open at the time except #291). +Frozen at tag `test-dev/v1.0.0-d6b7cbd-2026-07-22`. + +Superseded because `origin/main` advanced by 4 commits (including an iOS build-number bump to 166) and five PRs opened afterwards — #361, #362, #363, #364, #368. + +Conflicts A–F above were all first encountered and resolved in run 1; conflict B took a different form (`CacheAlertModal.test.tsx`, resolved by keeping #341's relaxed assertions), and #341 × #339 conflicted across 6 files before that branch was rebased. +Run 1 also carried three fixes cherry-picked back to the PRs they belonged to — `c9a78cd` (step-key restore), `d206e24` / `0662707` (#360's `ParseKeys` typing and stale test copy), all of which are now upstream in the PR branches and arrive here through the normal merges. diff --git a/docs/pre-relase-backlog-and-roadmap-analysis-2026-07-18-open-items.md b/docs/pre-relase-backlog-and-roadmap-analysis-2026-07-18-open-items.md new file mode 100644 index 00000000..ea3d8f2a --- /dev/null +++ b/docs/pre-relase-backlog-and-roadmap-analysis-2026-07-18-open-items.md @@ -0,0 +1,56 @@ +# Pre-release Open Items by Severity — status update + +> **Derived from:** [`pre-relase-backlog-and-roadmap-analysis-2026-07-18.md`](./pre-relase-backlog-and-roadmap-analysis-2026-07-18.md) +> **Original snapshot:** 2026-07-18 · **Status update:** 2026-07-19 +> **Scope:** Unfinished work only. Items closed by open PRs since the snapshot are listed under Resolved, not in the open tables. + +## Sorting and scale + +- **Priority:** P0 release gate · P1 before release · P2 soon · P3 defer +- **Effort:** XS `<0.5d` · S `0.5–1d` · M `2–3d` · L `4–5d` · XL `>1w` + +## Resolved / in flight (since 2026-07-18) + +| Item | Status | PR | +|---|---|---| +| Feature 35 — crash-report consent model | In flight — option (a): explicit feedback self-inits Sentry like one-shot crash | [#343](https://github.com/RonenMars/threadbase-mobile/pull/343) | +| Feature 36 — privacy checklist (code-side) | Partial in flight — code-verified items marked; human/store/legal remain | [#343](https://github.com/RonenMars/threadbase-mobile/pull/343) | +| Bug 5 — multi-attachment / spaced `@path` | In flight — escape spaces in composer; streamer sanitize is pair PR | [#345](https://github.com/RonenMars/threadbase-mobile/pull/345) | +| Bug 16 — abandoned empty session | In flight — stop unused fresh PTY on back | [#346](https://github.com/RonenMars/threadbase-mobile/pull/346) | + +## Critical severity (still open) + +| Item | Priority | Effort | Status | Type | +|---|---:|---:|---|---| +| [E2E remaining work](./e2e-remaining-work.md) — Maestro release suite | P0 | M | Partial — unit E2E pass; native Maestro release suite not green | Maintenance / test | +| Feature 36 — production/legal/store evidence | P0 | M | Partial — code path improved in #343; store consoles, legal, Sentry dashboard, on-device checks remain human-only | Privacy | + +## High severity (still open) + +| Item | Priority | Effort | Status | Type | +|---|---:|---:|---|---| +| Expo Router typed-route / typecheck failures | P0 | S | Open — CI credibility; not first-session UX | Bug / CI | +| [Feature 17](./ROADMAP.md#feature-17--expand-maestro-e2e-coverage-to-high-value-flows) — Maestro coverage | P0 | M | Partial — suite still ungreen | Maintenance / test | +| [Bug 2](./BACKLOG.md#bug-2--hub-tree-node-open-loader--long-list-render-stall) / [Issue 2](./BACKLOG.md#issue-2--hub-accordion-expand-stalls-on-long-projects-1266-items--9-s) — hub accordion stall | P1 | L | Open — only critical if demos expand huge trees | Bug / performance | + +## Medium severity (still open) + +| Item | Priority | Effort | Status | Type | +|---|---:|---:|---|---| +| Bug 18 / Bug 19 — skipped Maestro flows | P1 | S | Open | Maintenance / test | +| Feature 20 — screenshot regression gate | P2 | S | Open | Maintenance / test | +| Bug 17 — jumpy stream/reconnect scroll | P2 | M | Partial | Bug | +| Feature 5 — onboarding polish closeout | P2 | M | Partial — elevate for OSS first impressions | Task / UX | +| Feature 14 / 16 / 19 — voice / sync / queue polish | P2 | M–L | Partial | Feature | + +## Low severity + +Unchanged from 2026-07-18: Mission Control, saved views, snippets, Live Activities, scheduled prompts, memoization audit, bottom-sheet spike, etc. Remain P3 / defer. + +## Release-focused shortlist (updated) + +1. Merge Feature 35/36 consent PR (#343); finish human-only privacy/store checklist items before public posts. +2. Merge Bug 5 (#345) + streamer upload sanitize; smoke-test multi-attachment with spaced filenames. +3. Merge Bug 16 (#346); confirm abandoned browse→start sessions do not linger. +4. Restore typecheck green and Maestro release suite (maintainer gates; not stranger first-session blockers). +5. Optional for demos: hub accordion Issue 2 if large project trees are shown. diff --git a/docs/pre-relase-backlog-and-roadmap-analysis-2026-07-18.md b/docs/pre-relase-backlog-and-roadmap-analysis-2026-07-18.md new file mode 100644 index 00000000..58d7bcbb --- /dev/null +++ b/docs/pre-relase-backlog-and-roadmap-analysis-2026-07-18.md @@ -0,0 +1,132 @@ +# 🚦 Pre-release Backlog & Roadmap Analysis — 2026-07-18 + +> **Snapshot:** `origin/main` at `94145f1b82bd8d97754b595c005f1df7161d221f` (fetched 2026-07-18)
+> **Status update:** 2026-07-19 — live open-items: [`pre-relase-backlog-and-roadmap-analysis-2026-07-18-open-items.md`](./pre-relase-backlog-and-roadmap-analysis-2026-07-18-open-items.md)
+> **Sources:** [`BACKLOG.md`](./BACKLOG.md), [`ROADMAP.md`](./ROADMAP.md), current code, current Git history, and focused verification commands
+> **Scope:** Active, partial, moved, and cleanup entries. Entries already filed under the documents' explicit **Shipped** sections are not duplicated here. + +## 🚨 Release verdict + +**Tables below are the frozen 2026-07-18 snapshot.** As of 2026-07-19: + +1. 🟡 **Crash-reporting consent (Feature 35)** — in flight: [#343](https://github.com/RonenMars/threadbase-mobile/pull/343) (option a). +2. 🟡 **Privacy checklist (Feature 36)** — code-side progress in #343; store/legal/on-device evidence still human-only. +3. 🟡 **Bug 5 (multi-attachment / spaced `@path`)** — in flight: [#345](https://github.com/RonenMars/threadbase-mobile/pull/345). +4. 🟡 **Bug 16 (abandoned empty sessions)** — in flight: [#346](https://github.com/RonenMars/threadbase-mobile/pull/346). +5. 🔴 **Type check still red** and **Maestro release suite still ungreen** — maintainer gates, not first-session product blockers for an OSS invite. + +Do not treat the frozen table rows for Features 35/36 or Bugs 5/16 as current — see the open-items status doc. + +## 🗝️ Legend + +- **Severity:** 🔴 Critical · 🟡 High · 🔵 Medium · 🟢 Low · ⚪ None +- **Priority:** 🔴 P0 release gate · 🟡 P1 before release · 🔵 P2 soon · 🟢 P3 close/defer +- **Effort:** 🟢 XS `<0.5d` · 🔵 S `0.5–1d` · 🟡 M `2–3d` · 🔴 L `4–5d` · 🟣 XL `>1w` +- **Implementation:** ✅ Yes · 🟡 Partial · 🔎 Code present, runtime verification pending · ❌ No · ♻️ Replaced/obsolete · ➡️ Moved +- **Age:** calendar age as of 2026-07-18, based on `Filed` date where present and Git history otherwise. + +## 🐛 Backlog and issue analysis + +| Item | Estimated severity level | Estimated priority level | Estimated effort | Already implemented on main? | Age | Type | +|---|---:|---:|---:|---|---:|---| +| [Bug 2](./BACKLOG.md#bug-2--hub-tree-node-open-loader--long-list-render-stall) — Hub loader + long-list stall umbrella | 🟡 High | 🟡 P1 | 🔴 L | 🟡 Partial — Issue 1 fixed; Issue 2 remains | 57d · 2026-05-22 | 🧭 Other / umbrella | +| [Bug 5](./BACKLOG.md#bug-5--multi-attachment-send-produces-no-output) — multi-attachment send has no output | 🟡 High | 🟡 P1 | 🟢 XS verification | 🟡 Partial — multi-select/upload/send code shipped in `c70a498`; real multi-file response still lacks a green Maestro case | 61d · 2026-05-18 | 🐛 Bug | +| [Bug 7](./BACKLOG.md#bug-7--quick-access-strip-default-collapsed--tab-reorder--hide-when-fully-empty) — Quick Access defaults/order/empty state | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes, then simplified to Favorites-only in `c43e9a1` | 57d · 2026-05-22 | 🐛 Bug | +| [Bug 8](./BACKLOG.md#bug-8--manage-favorites-duplicate-top-bar) — duplicate Manage Favorites header | 🔵 Medium | 🟢 P3 | 🟢 XS docs | ✅ Yes — one native Stack header, no in-screen duplicate | 57d · 2026-05-22 | 🐛 Bug | +| [Bug 9](./BACKLOG.md#bug-9--quick-access-hide-edit-pencil-when-strip-is-collapsed) — pencil visible while collapsed | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — pencil and gear are gated on `!stripCollapsed` | 57d · 2026-05-22 | 🐛 Bug | +| [Issue 1](./BACKLOG.md#issue-1--post-intro-cached-hub-list-flashes-then-re-paints-with-server-data) — cached Hub flashes before refresh | 🔵 Medium | 🟢 P3 | 🟢 XS docs | ✅ Yes — cached-data refresh indicator shipped in `2a3e6d8` | 57d · 2026-05-22 | 🐛 Bug | +| [Issue 2](./BACKLOG.md#issue-2--hub-accordion-expand-stalls-on-long-projects-1266-items--9-s) — accordion expansion stalls ~9s | 🟡 High | 🟡 P1 | 🔴 L | ❌ No — `ProjectHubCard` still sorts/maps every expanded row inline | 57d · 2026-05-22 | 🐛 Bug / performance | +| [Bug 13](./BACKLOG.md#bug-13--new-session-name-modal-flashes-open-then-auto-closes-before-user-can-type) — create-name modal auto-closes | 🟡 High | 🟢 P3 | 🟢 XS docs | ♻️ Replaced — create/exit name modals were removed in `6c138c8`; rename remains available | 56d · 2026-05-23 | 🐛 Bug | +| [Bug 14](./BACKLOG.md#bug-14--after-starting-new-session-file-browser-stays-in-stack-and-re-shows-on-exit) — Browse remains under new session | 🔴 Critical | 🟢 P3 | 🟢 XS docs | ✅ Yes — current `/session/new` dismiss-then-push flow shipped in `0909404` | 56d · 2026-05-23 | 🐛 Bug | +| [Bug 15](./BACKLOG.md#bug-15--after-new-session-back-file-browser-is-interaction-locked-only-close-works) — stale Browse screen interaction-locks | 🟡 High | 🟢 P3 | 🟢 XS docs | ✅ Yes — eliminated with the Bug 14 navigation replacement | 55d · 2026-05-24 | 🐛 Bug | +| [Bug 16](./BACKLOG.md#bug-16--back-from-never-typed-in-new-session-leaves-an-empty-session-alive) — abandoned empty session stays alive | 🔵 Medium | 🔵 P2 | 🟡 M | ❌ No — no discard/delete-on-unused-exit path found | 55d · 2026-05-24 | 🐛 Bug | +| [Bug 17](./BACKLOG.md#bug-17--chat-output--on-reconnect-scroll-to-bottom-is-jumpy-not-smooth) — jumpy stream/reconnect autoscroll | 🔵 Medium | 🔵 P2 | 🟡 M | 🟡 Partial — terminal/history use native bottom anchoring; live chat still issues animated `scrollToEnd` calls | 55d · 2026-05-24 | 🐛 Bug | +| [Bug 18](./BACKLOG.md#bug-18--maestro-flow-server_drag_reorderyamlskip-crashes-the-app-at-the-swipe-step) — drag-reorder Maestro crash | 🔵 Medium | 🟡 P1 | 🔵 S | 🟡 Partial — flow exists, but is absent from `test:e2e:mock` and lacks a recorded green run | 55d · 2026-05-24 | 🔧 Maintenance / test | +| [Bug 19](./BACKLOG.md#bug-19--maestro-flow-tree_server_headersyamlskip-cant-return-to-hub-after-pairing-second-server) — tree headers E2E cannot finish pairing | 🔵 Medium | 🟡 P1 | 🔵 S | ❌ No — flow remains `tree_server_headers.yaml.skip` | 55d · 2026-05-24 | 🔧 Maintenance / test | +| [Cleanup: locked agent worktrees](./BACKLOG.md#locked-agent-worktrees-in-claudeworktrees) | ⚪ None | 🟢 P3 | 🟢 XS docs | ♻️ Obsolete — named worktrees/branches no longer exist | 54d · 2026-05-25 | 🧹 Maintenance | +| [Cleanup: uncommitted E2E/research](./BACKLOG.md#uncommitted-e2e--research-changes-on-main) | ⚪ None | 🟢 P3 | 🟢 XS docs | ♻️ Obsolete — current tree is clean; files were committed, replaced, or removed | 54d · 2026-05-25 | 🧹 Maintenance | +| [Cleanup: TestFlight build after conversation fix](./BACKLOG.md#testflight-build-with-the-conversation-load-fix) | ⚪ None | 🟢 P3 | 🟢 XS docs | ♻️ Obsolete — many builds followed; current iOS build is 164 | 54d · 2026-05-25 | 🧹 Maintenance | +| [Bug 20](./BACKLOG.md#bug-20--new-session-from-tree-view-with-path-completion-errors-on-path) — tree prefilled path rejected | 🟡 High | 🟢 P3 | 🟢 XS docs | ✅ Yes — out-of-root paths fall back to Browse root in `55eb9a8` | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 21](./BACKLOG.md#bug-21--open-session-from-recents-lands-on-session-not-found) — Recents opens wrong route | 🟡 High | 🟢 P3 | 🟢 XS docs | ♻️ Replaced — fixed in `a6a4f9f`; Recents was later removed in `c43e9a1` | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 22](./BACKLOG.md#bug-22--settings-qr-scanner-button-is-a-no-op-on-the-ui-layer) — Settings QR scanner is a no-op | 🟡 High | 🟢 P3 | 🟢 XS docs | ✅ Yes — scanner modal wiring + E2E shipped in `9335ca0` | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 23](./BACKLOG.md#bug-23--popular--new-session-here-errors-unable-to-load-directories) — Popular routes to wrong server | 🟡 High | 🟢 P3 | 🟢 XS docs | ♻️ Replaced — fixed in `3a0d061`; Popular was later removed | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 24](./BACKLOG.md#bug-24--popular-error-text-is-black-on-black-almost-invisible) — Popular error contrast | 🔵 Medium | 🟢 P3 | 🟢 XS docs | ♻️ Replaced — themed in `3a0d061`; Popular was later removed | 54d · 2026-05-25 | 🐛 Bug / visual | +| [Bug 25](./BACKLOG.md#bug-25----moved-to-roadmap) — Settings button parity | ⚪ None | 🟢 P3 | 🟢 XS docs | ➡️ Moved to Feature 22; ✅ shipped in `b82834d` | 54d · 2026-05-25 | 🧭 Other / moved | +| [Bug 26](./BACKLOG.md#bug-26--hide-quick-access-edit-pencil-when-the-active-tab-is-empty) — pencil on empty tab | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — empty strip returns `null`; pencil also requires items | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 27](./BACKLOG.md#bug-27----moved-to-roadmap) — optional server-name onboarding slide | ⚪ None | 🟢 P3 | 🟢 XS docs | ➡️ Moved to Feature 23; ✅ shipped in `9335ca0` | 54d · 2026-05-25 | 🧭 Other / moved | +| [Bug 28](./BACKLOG.md#bug-28--pull-to-refresh-modal-show-ipport-when-server-has-no-name) — unnamed server display fallback | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — `serverDisplayName` host:port fallback in `9335ca0` | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 29](./BACKLOG.md#bug-29--quick-access-open-only-on-tab-click-remove-the-right-side-chevron) — Quick Access expansion affordance | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — tab toggles strip; right chevron is gone | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 30](./BACKLOG.md#bug-30--add-to-favorites-is-non-functional--needs-spec-from-claude-code) — Favorites are non-functional | 🟡 High | 🟢 P3 | 🟢 XS docs | ✅ Yes — toggles across detail/Hub/Tree shipped in `ada009b`, rollback polish in `2a773aa` | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 31](./BACKLOG.md#bug-31--settings-theme-change-doesnt-apply-colors-across-the-whole-app) — theme does not propagate app-wide | 🟡 High | 🟢 P3 | 🟢 XS docs | ✅ Yes — app-wide `useTheme()` migration shipped in `a986613` | 54d · 2026-05-25 | 🐛 Bug | +| [Bug 33](./BACKLOG.md#bug-33--browsesession-navigation-simplify-the-transitionend-dismiss-then-push-dance) — simplify Browse navigation dance | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — transition listener replaced by `/session/new` + next-frame push in `0909404` | 48d · 2026-05-31 | 🔧 Maintenance | +| [E2E remaining work](./e2e-remaining-work.md) — mock suite failures and environment blockers | 🔴 Critical | 🔴 P0 | 🟡 M | 🟡 Partial — unit-level E2E tests pass, but native Maestro release suite is not green | 5d · 2026-07-13 | 🔧 Maintenance / test | + +## 🗺️ Roadmap analysis + +Missing roadmap features are generally **not release defects** unless they represent an already-advertised capability, a privacy/store obligation, or a verification gate. Their severity therefore stays low even when effort is large. + +| Item | Estimated severity level | Estimated priority level | Estimated effort | Already implemented on main? | Age | Type | +|---|---:|---:|---:|---|---:|---| +| [Feature 3](./ROADMAP.md#feature-3--attach-multiple-files-to-a-single-message) — multiple attachments | 🟡 High | 🟡 P1 | 🟢 XS verification | ✅ Yes in `c70a498`; pair with Bug 5 runtime verification | 55d · 2026-05-24 | 📋 Task / feature | +| [Feature 4](./ROADMAP.md#feature-4--auto-deploy-to-app-store--google-play) — automated store deploy | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — manual GitHub Actions deploy supports iOS/Android/all, with store upload and version landing | 57d · 2026-05-22 | 🔧 Maintenance / release | +| [Feature 5](./ROADMAP.md#feature-5--polish-the-onboarding-flow) — onboarding polish | 🔵 Medium | 🔵 P2 | 🟡 M | 🟡 Partial — multiple redesign/copy/error passes shipped; undefined audit/top-five closeout remains | 57d · 2026-05-22 | 📋 Task / UX | +| [Feature 6](./ROADMAP.md#feature-6--cross-session-search-with-hit-context--open-in-session) — search with anchored open | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — cross-server results, anchor loading, and highlight shipped in `d71ade4` | 57d · 2026-05-22 | 📋 Task / feature | +| [Feature 7](./ROADMAP.md#feature-7--workspace-tagging-across-sessions--conversations--projects) — workspace tagging | 🟢 Low | 🟢 P3 | 🟣 XL | ❌ No | 57d · 2026-05-22 | 📋 Task / feature | +| [Feature 8](./ROADMAP.md#feature-8--saved-views-persisted-filter--sort--tag-combos-as-named-tabs) — saved views | 🟢 Low | 🟢 P3 | 🔴 L | ❌ No | 57d · 2026-05-22 | 📋 Task / feature | +| [Feature 9](./ROADMAP.md#feature-9--side-by-side-session-split-view-for-live-runs) — split session view | 🟢 Low | 🟢 P3 | 🟣 XL | ❌ No | 57d · 2026-05-22 | 📋 Task / feature | +| [Feature 10](./ROADMAP.md#feature-10--cross-server-prompt-templates--snippets-library) — snippets library | 🟢 Low | 🟢 P3 | 🔴 L | ❌ No — slash-command UI is not a user snippet store | 57d · 2026-05-22 | 📋 Task / feature | +| [Feature 11](./ROADMAP.md#feature-11--workspace-sync-across-devices-via-streamer) — cross-device workspace sync | 🟢 Low | 🟢 P3 | 🟣 XL | ❌ No | 57d · 2026-05-22 | 📋 Task / cross-repo | +| [Feature 12](./ROADMAP.md#feature-12--live-activities--dynamic-island-for-in-progress-sessions) — Live Activities / Dynamic Island | 🟢 Low | 🟢 P3 | 🟣 XL | ❌ No | 57d · 2026-05-22 | 📋 Task / native | +| [Feature 13](./ROADMAP.md#feature-13--mission-control-aggregate-every-live-session-across-servers) — Mission Control | 🟢 Low | 🔵 P2 | 🟣 XL | ❌ No | 57d · 2026-05-22 | 📋 Task / feature | +| [Feature 14](./ROADMAP.md#feature-14--voice-prompts-via-on-device-whisper) — voice prompts | 🔵 Medium | 🔵 P2 | 🟡 M | 🟡 Partial — OS speech recognition shipped in `bf583f0`/`c70a498`; offline Whisper, waveform, and silence-stop did not | 57d · 2026-05-22 | 📋 Task / native | +| [Feature 15](./ROADMAP.md#feature-15--scheduled-prompts-send-tomorrow-at-9am) — scheduled prompts | 🟢 Low | 🟢 P3 | 🟣 XL | ❌ No | 57d · 2026-05-22 | 📋 Task / cross-repo | +| [Feature 16](./ROADMAP.md#feature-16--sync-mode-jsonl-sourced-bubbles--native-prompt-forms) — JSONL bubbles + native prompt forms | 🔵 Medium | 🔵 P2 | 🔴 L | 🟡 Partial — live bubbles and structured prompts shipped; the specified per-session Sync toggle/model was re-scoped | 56d · 2026-05-23 | 📋 Task / cross-repo | +| [Feature 17](./ROADMAP.md#feature-17--expand-maestro-e2e-coverage-to-high-value-flows) — high-value Maestro coverage | 🟡 High | 🔴 P0 | 🟡 M | 🟡 Partial — coverage expanded substantially, but the release suite remains ungreen | 56d · 2026-05-23 | 🔧 Maintenance / test | +| [Feature 19](./ROADMAP.md#feature-19--queue-while-thinking-recolor-send-button-as-add-to-queue-during-a-turn-auto-send-when-idle) — queue while thinking | 🔵 Medium | 🔵 P2 | 🔴 L | 🟡 Partial — queue API/sheet exists, but the composer/send-button behavior and automatic idle flush do not | 55d · 2026-05-24 | 📋 Task / feature | +| [Feature 20](./ROADMAP.md#feature-20--visual-regression-gate-on-maestro-screenshots) — screenshot regression gate | 🔵 Medium | 🔵 P2 | 🔵 S | ❌ No — screenshots exist; comparator/baselines/CI gate do not | 55d · 2026-05-24 | 🔧 Maintenance / test | +| [Feature 21](./ROADMAP.md#feature-21--tree-view-render-drilled-folder-conversations-as-full-hubclassic-rows) — rich Tree drill rows | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — `DrillRow` uses shared `ConversationListItem` with preview/count/branch/provider | 54d · 2026-05-25 | 📋 Task / UX | +| [Feature 22](./ROADMAP.md#feature-22--settings-button-on-the-filter--sort-bar-parity-with-sidebar) — Settings button parity | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes in `b82834d` | 54d · 2026-05-25 | 📋 Task / UX | +| [Feature 23](./ROADMAP.md#feature-23--onboarding-optional-server-name-slide-before-the-qr-scan) — optional server-name slide | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes in `9335ca0` | 54d · 2026-05-25 | 📋 Task / UX | +| [Feature 24](./ROADMAP.md#feature-24--manage-favorites-add-to-favorites-empty-state-cta) — Favorites empty-state CTA | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — CTA exists and Favorites are functional | 54d · 2026-05-25 | 📋 Task / UX | +| [Feature 25](./ROADMAP.md#feature-25--clear-react-hooks-v5-lint-warnings-react-compiler-prereq) — clear hooks lint warnings | 🟢 Low | 🟢 P3 | 🟢 XS docs | ✅ Yes — full ESLint pass is clean with zero warnings | 52d · 2026-05-27 | 🔧 Maintenance | +| [Feature 26](./ROADMAP.md#feature-26--verify-sdk-56-precompiled-xcframeworks-are-active-in-maestro-ci) — verify precompiled XCFrameworks | 🟢 Low | 🔵 P2 | 🔵 S | 🟡 Partial — precompile is explicitly enabled; clean CI-log evidence and timing comparison are not documented | 52d · 2026-05-27 | 🔧 Maintenance / performance | +| [Feature 27](./ROADMAP.md#feature-27--adopt-eas-precompiled-community-libs-for-ios-build-time) — EAS community precompile | 🟢 Low | 🟢 P3 | 🔵 S | ♻️ Not applicable to current self-hosted Actions path; EAS remains opt-in | 52d · 2026-05-27 | 🔧 Maintenance / performance | +| [Feature 28](./ROADMAP.md#feature-28--audit-manual-usememousecallbackreactmemo-for-react-compiler-driven-deletion) — manual memoization audit | 🟢 Low | 🟢 P3 | 🟡 M | ❌ No — React Compiler is on, but 187 memoization calls remain and no profile-driven audit is recorded | 52d · 2026-05-27 | 🔧 Maintenance / performance | +| [Feature 29](./ROADMAP.md#feature-29--spike-swap-gorhombottom-sheet-for-sdk-56s-drop-in-replacement) — bottom-sheet replacement spike | 🟢 Low | 🟢 P3 | 🔵 S | ❌ No — `@gorhom/bottom-sheet` remains the active dependency | 52d · 2026-05-27 | 🔧 Maintenance / dependency | +| [Feature 30](./ROADMAP.md#feature-30--build-time-warning-cleanup-ship-121-follow-ups) — build warning cleanup | 🟢 Low | 🟢 P3 | 🔵 S | 🟡 Partial — Fastlane is now 2.236.1 and SDK moved to 57; remaining warning acceptance is undocumented | 48d · 2026-05-31 | 🔧 Maintenance | +| [Feature 31](./ROADMAP.md#feature-31--reuse-the-fly-demo-server-as-a-stable-backend-for-maestro-and-visual-regression-tests) — reuse Fly demo server | 🟢 Low | 🟢 P3 | 🟡 M | ♻️ Re-scoped/obsolete — `demo-server/` no longer exists; demo Maestro flows remain separate | 47d · 2026-06-01 | 🔧 Maintenance / infrastructure | +| [Feature 32](./ROADMAP.md#feature-32--handle-batched-conversation_events-ws-event) — batched conversation WS events | 🟡 High | 🟢 P3 | 🟢 XS docs | ✅ Yes — WS union and shared batch handler are present | 23d · 2026-06-25 | 🐛 Bug / compatibility | +| [Feature 33](./ROADMAP.md#feature-33--repair-stale-demo-server-yaml-e2e-flows-selector-drift--maestro-2x-syntax) — repair demo E2E selectors | 🔵 Medium | 🟡 P1 | 🟢 XS verification | 🔎 Code present in `c9ac740`; green demo run still required | 18d · 2026-06-30 | 🔧 Maintenance / test | +| [Feature 34](./ROADMAP.md#feature-34--structured-prompt-cards-for-codex-sessions) — Codex structured prompts | 🟢 Low | 🟢 P3 | 🔴 L | ❌ No on mobile `main`; explicitly streamer-side | 14d · 2026-07-04 | 📋 Task / cross-repo | +| [Feature 35](./ROADMAP.md#feature-35--decide-the-crash-reporting-consent-model-auto-init-vs-explicit-only) — crash-report consent model | 🟡 High | 🔴 P0 | 🔵 S | ❌ No — current one-shot crash and feedback paths still use different consent behavior | 5d · 2026-07-13 | 🧭 Other / product decision | +| Feature 36 — validate privacy checklist and crash-reporting UX recommendation | 🔴 Critical | 🔴 P0 | 🟡 M | 🟡 Partial — core sanitizer/config code exists; production, legal, push, speech, and store checks remain open | 2d · 2026-07-16 | 🔧 Maintenance / privacy | + +## 🆕 Verification finding not yet tracked in either source + +| Item | Estimated severity level | Estimated priority level | Estimated effort | Already implemented on main? | Age | Type | +|---|---:|---:|---:|---|---:|---| +| Expo Router typed-route errors: nine `string` → `Href` failures across navigation call sites | 🟡 High | 🔴 P0 | 🔵 S | ❌ No — `npm run typecheck` fails on current `main` | ≤6d · introduced across 2026-07-12–17 | 🐛 Bug / CI | + +Affected files: `app/_layout.tsx`, `app/index.tsx`, `app/session/[id].tsx`, `components/conversation/ConversationList.tsx`, `components/sessions/hub/ProjectHubList.tsx`, and `components/sessions/tree/TreeSessionsList.tsx`. + +## ✅ Verification performed + +| Check | Result | +|---|---| +| `git fetch origin main --prune` | ✅ Current checkout equals `origin/main` at `94145f1` | +| `npx eslint . --max-warnings=0` | ✅ Passed | +| `npm run test:ci -- --runInBand` | ✅ 107 suites; 1,038 passed; 1 skipped | +| `npm run typecheck` | ❌ Failed with nine typed-route errors | +| `git diff --check` | ✅ Passed | +| Working tree before report creation | ✅ Clean | +| Native Maestro release suite | ⚠️ Not run: `e2e/check-sim.js` found no booted iOS simulator; the repo's own remaining-work report documents unresolved failures and a Browse crash | + +The Jest run also emitted non-fatal test-harness warnings (overlapping/unwrapped `act`, one missing child key, Watchman recrawl noise, and forced exit from open handles). They do not fail the suite, but should be cleaned up after the P0 release gates. + +## 🎯 Recommended pre-release sequence + +1. 🔴 Fix the nine type errors, then rerun Type check, lint, and Jest. +2. 🔴 Reproduce the Browse Maestro crash and get `test:e2e:mock` green on the supported simulator; include or explicitly defer `server_drag_reorder` and `tree_server_headers` with a recorded decision. +3. 🔴 Choose Feature 35's consent model and complete Feature 36's production/privacy/store checks, including raw Sentry-event inspection. +4. 🟡 Run physical-device smoke checks for new-session navigation, two-file attachment send/response, Hub expansion on a large project, abandoned-empty-session behavior, QR pairing, search-anchor navigation, and theme switching. +5. 🟢 Reconcile `BACKLOG.md` and `ROADMAP.md`: close or rewrite stale entries so the next release review starts from current truth. diff --git a/docs/privacy-policy/privacy-follow-up-checklist.md b/docs/privacy-policy/privacy-follow-up-checklist.md index 32402512..7b1dd4e8 100644 --- a/docs/privacy-policy/privacy-follow-up-checklist.md +++ b/docs/privacy-policy/privacy-follow-up-checklist.md @@ -1,58 +1,68 @@ # Threadbase Privacy Policy -- Follow-up Checklist +> **Last reviewed:** 2026-07-18 (Feature 36 code-side validation). +> Items marked with checkmarks have been verified from code. Items still unchecked require human action (store console, legal review, or production inspection). + ## High priority -- Verify Sentry SDK configuration. -- Confirm `sendDefaultPii = false`. -- Verify server-side IP scrubbing. -- Audit `beforeSend` sanitization. -- Verify Session Replay, Profiling, Performance Monitoring, and - Tracing are disabled. -- Verify breadcrumbs (console, HTTP, navigation) are appropriate. +- [x] Verify Sentry SDK configuration. _(Verified: `services/sentry.ts` uses hardened `Sentry.init()` with all privacy options correctly set.)_ +- [x] Confirm `sendDefaultPii = false`. _(Verified: line ~177 in `services/sentry.ts`.)_ +- [ ] Verify server-side IP scrubbing. _(Requires Sentry project settings inspection — human-only.)_ +- [x] Audit `beforeSend` sanitization. _(Verified: `beforeSend` routes through `sanitizeEvent` in `services/sanitize.ts`.)_ +- [x] Verify Session Replay, Profiling, Performance Monitoring, and Tracing are disabled. _(Verified: `replaysSessionSampleRate: 0`, `replaysOnErrorSampleRate: 0`, `tracesSampleRate: 0`, no performance integrations.)_ +- [x] Verify breadcrumbs (console, HTTP, navigation) are appropriate. _(Verified: `filterIntegrations` blocks Breadcrumbs, HttpContext, DeviceContext, etc. Only explicit `addSafeBreadcrumb` calls are allowed.)_ ## Crash reporting -- Verify exactly which fields are sent for: - - JS exception - - Native iOS crash - - Native Android crash - - Manual crash report -- Confirm installation identifier lifecycle. -- Verify opt-in/opt-out behavior. +- [x] Verify exactly which fields are sent for: + - [x] JS exception _(Verified: normalized via `normalizeError` → sanitized via `beforeSend`.)_ + - [ ] Native iOS crash _(Requires on-device test + Sentry dashboard inspection — human-only.)_ + - [ ] Native Android crash _(Requires on-device test + Sentry dashboard inspection — human-only.)_ + - [x] Manual crash report _(Verified: same `doCaptureException` path as automatic; `reportOneShot` uses identical sanitization.)_ +- [x] Confirm installation identifier lifecycle. _(Verified: `getSentryInstallId` / `clearSentryInstallId` in `services/sentry-install-id.ts`; cleared on disable.)_ +- [x] Verify opt-in/opt-out behavior. _(Verified: `initCrashReporting` requires `consentGranted=true`; `setCrashReportingEnabled(false)` calls `disableCrashReporting` which closes client + clears install id.)_ +- [x] Verify consent model consistency. _(Verified 2026-07-18: Feature 35 implemented option (a) — both `reportOneShot` and `submitFeedbackViaSentry` self-init for explicit user actions, then tear down if standing consent was off.)_ ## Feedback -- Finalize feedback transport architecture. -- Document retention period. -- Document attachment handling. +- [x] Finalize feedback transport architecture. _(Verified: `services/feedback-transport.ts` implements Sentry → email → copy fallback chain; Sentry path now self-inits like crash reports.)_ +- [ ] Document retention period. _(Retention is Sentry project default (90 days); needs explicit statement in published policy — human-only.)_ +- [x] Document attachment handling. _(Verified: `docs/privacy-policy/proposed-privacy-policy.md` describes screenshot handling; EXIF stripped by `pickAndPrepareScreenshot`.)_ ## Push notifications -- Audit notification payload. -- Ensure payload excludes prompts, terminal output, credentials, - repository information, and conversation content. +- [ ] Audit notification payload. _(Requires streamer-side code review and on-device inspection — human-only.)_ +- [ ] Ensure payload excludes prompts, terminal output, credentials, repository information, and conversation content. _(Requires streamer-side code review — human-only.)_ ## Privacy & Legal -- Replace Sentry region placeholder. -- Add controller/entity information if applicable. -- Review GDPR/UK GDPR wording. -- Add international transfer wording matching production. -- Review deletion request flow. +- [ ] Replace Sentry region placeholder. _(Requires Sentry project settings — human-only.)_ +- [ ] Add controller/entity information if applicable. _(Legal/entity decision — human-only.)_ +- [ ] Review GDPR/UK GDPR wording. _(Legal review — human-only.)_ +- [ ] Add international transfer wording matching production. _(Legal review — human-only.)_ +- [ ] Review deletion request flow. _(Requires documented process for handling email requests — human-only.)_ ## Speech recognition -- Verify on-device behavior on iOS and Android. -- Adjust wording if cloud processing is possible. +- [ ] Verify on-device behavior on iOS and Android. _(Requires on-device testing — human-only.)_ +- [ ] Adjust wording if cloud processing is possible. _(Depends on speech recognition test results — human-only.)_ ## Store compliance -- Update Apple App Privacy labels. -- Update Google Play Data Safety form. -- Verify third-party SDK disclosures. +- [ ] Update Apple App Privacy labels. _(App Store Connect — human-only.)_ +- [ ] Update Google Play Data Safety form. _(Play Console — human-only.)_ +- [ ] Verify third-party SDK disclosures. _(Store console review — human-only.)_ ## Final QA -- Compare policy against production implementation. -- Inspect raw Sentry events before release. -- Review policy after every SDK upgrade. +- [ ] Compare policy against production implementation. _(Requires on-device testing + Sentry dashboard inspection — human-only.)_ +- [ ] Inspect raw Sentry events before release. _(Requires Sentry dashboard access — human-only.)_ +- [ ] Review policy after every SDK upgrade. _(Ongoing process — human-only.)_ + +--- + +## Summary (2026-07-18) + +**Code-verified items:** 12 items verified from static code analysis. + +**Human-only items remaining:** 15 items require store console access, legal review, on-device testing, or Sentry dashboard inspection. These cannot be automated. diff --git a/docs/privacy-policy/proposed-privacy-policy.md b/docs/privacy-policy/proposed-privacy-policy.md index 7da58321..e6b248e0 100644 --- a/docs/privacy-policy/proposed-privacy-policy.md +++ b/docs/privacy-policy/proposed-privacy-policy.md @@ -119,7 +119,14 @@ Submitting feedback is always a deliberate action you take. Tapping the **Send** you provide one, and — only if you leave the "Include technical diagnostics" option enabled — the same sanitized technical diagnostics described for diagnostics below. Before you submit, the screen shows you exactly what will be included. Technical diagnostics are optional and can be disabled before sending. -- **How it is sent.** Depending on the feature you choose, feedback may be delivered through a configured feedback endpoint, the Sentry User Feedback channel, your device's email application, or a copy-to-clipboard option. Before anything is transmitted, Threadbase clearly indicates which delivery method will be used. Feedback submissions are always initiated by you and do not require automatic crash reporting to be enabled. +- **How it is sent.** When you tap Send, Threadbase first attempts to deliver + your feedback through the Sentry User Feedback channel. This works even if + you have not turned on automatic crash reporting — just like the one-time + "Report this crash" button, tapping Send is treated as explicit consent for + that single submission. If Sentry is unavailable, feedback falls back to your + device's email application, or a copy-to-clipboard option. The success screen + shows which delivery method was used. Feedback submissions are always + initiated by you and do not enable automatic crash reporting. - **Optional email.** If you provide a reply email, it is stored with your feedback so we can respond. Leaving it blank keeps your submission anonymous. - **What is excluded.** As with crash reports, feedback does not intentionally diff --git a/docs/runbooks/2026-07-22-land-open-prs.md b/docs/runbooks/2026-07-22-land-open-prs.md new file mode 100644 index 00000000..94745264 --- /dev/null +++ b/docs/runbooks/2026-07-22-land-open-prs.md @@ -0,0 +1,223 @@ +# Landing runbook — getting the open PR chain onto `main` + +**Source:** [`../integration-merge-report-2026-07-22.md`](../integration-merge-report-2026-07-22.md). Conflicts are named **A–I** and the two carry-beyond hazards live under "Standing hazard" there. The report is a **run log**: **Run 2 is current, Run 1 is superseded** — never carry a Run 1 resolution forward. +**Status:** live procedure — edit it as PRs land. + +The integration branch `integration-dev/v1.0.0-2026-07-22` proves these PRs *can* coexist and land green as a set. It does not land them. Each still goes onto `main` one at a time under the repo's rebase + squash rule, and **conflicts A–I recur when the same two branches meet on `main`.** This document maps them to an order so nobody meets them cold — but its single most important message is that the report records what *was* true at the snapshot, and every resolution is perishable: Run 2 itself watched `#341`'s six-file conflict *vanish* once its branch was rebased onto `#339`, and conflict **A**'s resolution depended on a byte-identical precondition that no longer holds (see below). **Re-verify the precondition, then apply the resolution — never the reverse.** + +## Who should run this + +Not uniform. Three phases are mechanical rebase-and-squash; two contain conflicts that git resolves *cleanly while deleting code*, where the only signal is a `tsc` error you have to already expect. + +| Phase | Character of the work | Model | Effort | +|---|---|---|---| +| **A** — mechanical independents | Rebase, check a *fresh* CI, squash. No documented conflict. | **Sonnet 5** (`claude-sonnet-5`) | **medium** | +| **B** — cache stack + live sessions (`#339`, `#341`, `#346`, `#354`) | Conflict **A** (must be rebuilt by hand now — see traps), one stacked `--onto` pair, and the `#354 × #346` navigation trap. | **Opus 4.8** (`claude-opus-4-8`) | **high**, **xhigh** for `#354` | +| **C** — i18n / onboarding locale cluster (`#356`, `#357`, `#360`, `#362`, `#364`, `#368`) | Conflicts **C, E, F, G, H, I**, including **F**, a locale-key deletion git applies with *no conflict marker*. Failure here is *absence*. | **Opus 4.8** (`claude-opus-4-8`) | **xhigh** | +| **D** — docs (`#347`, `#358`, `#372`) | Conflict **D** (`BACKLOG.md` + a `
` repair). Textual, predictable. | **Sonnet 5** (`claude-sonnet-5`) | **medium** | + +**On effort.** Not `low` even for the mechanical phases: every phase ends in an irreversible squash to `main`, and `low` is where "CI is green, ship it" wins over checking *which commit* CI tested — and on this repo a `[skip-ci]` head commit makes the required checks report green in seconds having run *nothing* (see "Known traps"). Phase C is `xhigh` on purpose and it is the one place the extra deliberation earns its cost: the dangerous outcome there is a clean auto-merge, a green `tsc` *on a stale pre-rebase run*, and a locale file quietly missing keys. Nothing prompts you to look — you have to already know to re-run `tsc` on the freshly rebased branch and grep for the keys. That is a "read this specific file and compare against a list" task; `xhigh` buys the patience to do it rather than assume. Not `max` anywhere — noticing an absence is not something more tokens per step buys. + +**Do not use Haiku for any phase.** Every phase ends in a squash-merge to `main`, and Phases B and C require deciding whether a green signal is trustworthy — the Run 2 log records four genuinely-broken suites nearly dismissed as flakes and two real load artifacts, indistinguishable in batch output. + +**Operating constraints:** + +- **One PR per session.** Context exhaustion mid-merge is how a resolution gets half-applied. Phases B and C deserve a fresh context per PR. +- **Never parallelise.** Each squash advances `main` and stales the next branch; two in flight guarantees a stale rebase. Rebase → wait for green → squash → move on. +- **Worktree must live outside `.claude/`.** A worktree under `.claude/` is excluded by `testPathIgnorePatterns`, so `npx jest` finds **0 tests** and the suite looks broken when it is not. This session used `worktrees/merge-prs-v2`. Each worktree needs its own `npm ci` (~3 min, 1292 packages). +- **Verify heavy suites serially.** `npx jest --ci --runInBand --testPathPattern "SessionScreen"`. A load artifact and a real defect look identical in batch output — **re-run any single failure in isolation before calling it a flake.** Passes alone → artifact. Fails alone → real, fix it. +- **Verify, do not infer.** A blocked compound shell command runs *none* of its parts; a negative `grep` may mean the wrong working directory or path (this session's first `#355` comparison reported `DIFFER` only because the path `lib/mergeLiveMessages.ts` was wrong — the file is `utils/mergeLiveMessages.ts`). + +## Before starting — decide these once + +| PR | Decision | +|---|---| +| **#291** `typescript 6 → 7` | **Excluded.** Standing request. It is `BLOCKED` on a failing `Lint` regardless; do not touch it. | +| **#355** `feat/live-external-sessions-integration` | **Close as superseded by `#354`.** Both are the mobile counterpart to streamer #253; the four core files are byte-identical, so closing loses no live-session capability, while `#355` (built on the superseded `bfc800d-2026-07-20` snapshot) is *missing* the ar/he/ru take-over translations `#354` carries and its only extra content is pre-formed cross-PR glue (the `#341 ∪ #354` union in `api-client.ts`, `#339`/`#341`'s `cache_alert` frames) that is not functionality and re-forms as conflict **A**. **Re-verify before closing** (perishable): `git rev-parse origin/feat/live-external-sessions:` vs `origin/feat/live-external-sessions-integration:` for `hooks/useConversationStream.ts`, `utils/mergeLiveMessages.ts`, `lib/externalSession.ts`, `app/conversation/[id].tsx` — all four hashes must match; and `#354`'s `locales/ar/conversation.json` must carry the two `takeOver` keys `#355` lacks. Verified 2026-07-23: 4/4 identical, `#354` = 2 keys, `#355` = 0. | +| **#373** `chore/deps-update-podfile-lock-2026-07-22` | **Land — but note it postdates the snapshot.** It was opened after Run 2 was cut and is therefore **not** one of the 20 PRs the report integration-tested. It is `ios/Podfile.lock` only (`MERGEABLE`/`CLEAN`), so the risk is low, but confirm it is still wanted before spending a squash on it. Not in Phase A/B/C/D conflict scope; land it as a mechanical independent. | + +**Count — the runbook is authoritative here.** **23 PRs are open** as of 2026-07-23 (the report's era had 21; `#373` and `#376` are both new since). `#376` is separate follow-up work, not part of this chain — see "Follow-up PRs" at the end. Run 2 merged **20** distinct PRs into the snapshot — confirmed by `git log --merges origin/main..HEAD`: `#339 #341 #343 #345 #346 #347 #353 #354 #355 #356 #357 #358 #359 #360 #361 #362 #363 #364 #368 #372`. The report's merge-log **table lists only 19** — `#372` (`docs/jest-suite-verification`) is merged and named in the prose but absent from the table. After excluding `#291` and closing `#355`, and adding the new `#373`, **20 PRs land.** + +## Pre-flight + +Sweep every open PR for mergeability and red checks before landing anything. Two blockers were sitting in the Run 2 set when it looked ready and neither was visible from a green integration branch. + +```bash +nums="291 339 341 343 345 346 347 353 354 355 356 357 358 359 360 361 362 363 364 368 372 373" +for n in $nums; do gh pr view $n --json mergeable -q .mergeable >/dev/null 2>&1; done # pass 1: triggers lazy compute +for n in $nums; do + gh pr view $n --json number,mergeable,mergeStateStatus,statusCheckRollup \ + -q '"\(.number)|\(.mergeable)|\(.mergeStateStatus)|\([.statusCheckRollup[]?|select(.conclusion=="FAILURE")|.name]|join(","))"' +done # pass 2: real values +``` + +**Run it twice.** GitHub computes mergeability lazily; the first `gh pr view` only *triggers* the computation and the bulk value comes back `UNKNOWN`. The second pass returns real states. Expect most PRs to read `BEHIND` — normal, handled by step 1 of the loop. You are hunting for `CONFLICTING`/`DIRTY` or a non-empty failing-checks column. + +Also re-list open PRs (`gh pr list --state open`) and diff the number set against the 22 above — a PR that appeared since (as `#373` did) means the branch set moved and this runbook's counts need re-confirming. + +Found this way, 2026-07-23: **`#291`** `BLOCKED` on failing `Lint` (excluded — ignore). **`#355`** `UNSTABLE` with `Integration tests` failing — that is the inherited `#346` `useNavigation` failure, and it is moot because `#355` is being closed. Everything else `MERGEABLE`/`CLEAN`, including `#341` (`CLEAN` **with a full green check suite** — see "Stacked pairs", this repo is not like others). + +## The per-PR loop + +Run this for one PR at a time. Never two in parallel. + +1. `git fetch origin && git rebase origin/main` on the PR branch. **Rebase immediately before merging, not in advance** — see "Moving target". +2. Resolve conflicts using the matching entry (**A–I**) in the report. **Re-verify that entry's stated precondition first** — do not apply a resolution whose precondition no longer holds. If the conflict is not one of A–I, stop (see "Stop points"). +3. If either side extracted code into a method, or you resolved a locale conflict, **read the resolved file, not just the marker.** Conflict markers only show the call site; the silent trap (**F**) shows no marker at all. See "Known traps". +4. `npx eslint ` (per repo rule), then `npx tsc --noEmit`, then the affected suites (`--runInBand` for `SessionScreen.*`). +5. `git push --force-with-lease` (never plain `--force`, never force-push `main`). +6. Wait for a **fresh, non-`[skip-ci]`** CI run to go green. If red on a suspected flake, re-run **once**; if still red, stop and report. +7. `gh pr merge --squash --delete-branch`. Conventional title, no AI attribution. +8. If the merged PR was the base of a stacked PR, immediately `--onto` rebase the child (see "Stacked pairs"). + +## Stop and wait for approval + +If an agent is running this unattended, these are hard stops. Present the evidence and wait; do not batch several past a stop. + +**Always stop before** (irreversible or outward-facing): + +1. **Any squash-merge to `main`** (step 7). The only irreversible step, and it changes a shared branch. Show: PR number, squash title, CI state (and that it was a *fresh, non-`[skip-ci]`* run), and — for Phases B/C — the result of the trap check. +2. **Any commit.** Repo rule, no exceptions: show `git diff --staged` and the exact message, then wait — even when told "just commit it". +3. **Any force-push** (step 5), including `--force-with-lease`. State what is being rewritten and why the rewrite is safe. +4. **Closing `#355`.** Closing is a judgment about someone's work. Present the re-verified byte-identical evidence from the decide-once table first. + +**Stop and ask when the situation is not the one this runbook describes** — these matter most, because they are where an agent confidently does the wrong thing: + +5. **A conflict that is not one of A–I**, or an A–I conflict whose stated precondition no longer holds (e.g. the pre-merge file is *not* byte-identical to what conflict A assumes). Report the files and hunks; do not improvise. +6. **A trap check that comes back missing** — the `connect.step1`/`step2` keys absent from any of the 4 onboarding locales after the cluster merges; the `api-client.ts` union missing `warmupState` *or* the 409 `ConversationBusyError` branch; the `SessionScreen.externalGate` suite red after both `#346` and `#354` are on `main`. A missing check means the resolution was wrong. +7. **CI red after exactly one re-run.** One re-run for a suspected flake, then stop. Before calling anything a flake, re-run the single suite in isolation with `--runInBand`. +8. **Pre-flight finds a blocker not listed here** — any `CONFLICTING`/`DIRTY` beyond `#291`, any new red check, or a new PR number in the open set. The branch set moved. +9. **A verification claim that cannot be proven mechanically.** If you cannot show a resolution is lossless (tree-hash match, clean reverse-apply, `tsc` green on the *rebased* branch), say "I could not verify this" rather than asserting it. + +**Do not stop for these** — the reversible majority; asking about each turns a 20-PR sequence into an interrogation: fetching, rebasing, resolving a conflict A–I already documents, running eslint/tsc/tests, reading files, `npm ci`, creating a worktree, or an `--onto` rebase of a stacked child. + +## Order + +Grouped by risk, not by number. Within a phase, order is free unless stated. + +**Phase A — mechanical independents. Land first.** No documented conflict; each is clean onto `main`. `#343`, `#345`, `#353` (dependabot, lockfile-only), `#359`, `#361`, `#363`, `#373`. Land `#363` here even though `#368` will later hit conflict **I** against it — `#363` is clean now and being on `main` first is what makes **I** a normal rebase for `#368`. + +**Phase B — cache stack + live sessions, in this order.** + +1. `#339` `feat/cache-integrity-alert` — clean onto `main`. It is the **base of `#341`**; land it first. +2. `#341` `feat/cache-warmup-status` — **stacked on `#339`** (`baseRefName`, not number). After `#339` squash-merges, `--onto` rebase it (see "Stacked pairs"). Its Run 1 six-file conflict is *gone* (rebased onto `#339`); do not expect it. +3. `#346` `fix/abandoned-empty-sessions` — clean onto `main`, but land it **before `#354`**: it introduces the `useNavigation` the `#354` external-gate suite needs. Confirm its upstream fix `b84f18c` is present (`git merge-base --is-ancestor b84f18c origin/fix/abandoned-empty-sessions`). +4. `#354` `feat/live-external-sessions` — expect conflict **A** on `services/api-client.ts`, now **rebuilt by hand** (the report's "take `#355`'s tip" shortcut dies with `#355` — see "Known traps"). Then the `#354 × #346` navigation check. Confirm upstream fixes `25c83b6` and `5b26bf7` are present. + +**Phase C — i18n / onboarding locale cluster. Highest risk.** Conflicts **C, E, F, G, H, I** live here, including the silent **F**. Recommended order `#356 → #357 → #360 → #362 → #364 → #368`, driven by real dependencies (**G**: `#362` reworks the manual-pairing step `#360` also touches, so `#360` first; **H**: `#364`'s comment conflict is against `#360`; **I**: `#368` vs already-landed `#363`). **Order does not defuse trap F** — the report proved with `git merge-tree` in both directions that the keys vanish either way. What defuses it is landing each PR only on a **fresh post-rebase green** (see "Known traps"). Land `#368` last so its own new `i18n` job validates the settled locale set; confirm `#362`'s upstream fixes `ec5260f` and `4c6a275` are present before it lands. + +**Phase D — docs, last.** `#347`, then `#358` (conflict **D**: take `#358`'s 2026-07-22 sync over `#347`'s 2026-07-19, and re-add the `` that `#343`'s `
` wrapper needs in `ROADMAP.md` — verify tag count balanced), then `#372` (independent). Landing docs last resolves `BACKLOG.md`/`ROADMAP.md` once against a settled `main` instead of on every merge. + +## Known traps + +Each of these produces a **green signal** while something is wrong. A trap that announces itself needs no runbook; these are the ones a careful operator still walks into. + +### F — `#356 × #360` locale-key deletion with no conflict marker (the centrepiece) + +`#356` deletes `connect.step1`/`connect.step2` as unused; `#360` adds the *usage* (`ConnectStep.tsx`) but inherits the keys from its base as plain context lines, so **git applies the deletion cleanly, no marker.** Worse: the merge *does* hand you a conflict — the `manualSectionLabel`/`footnote` hunk in the *same file* — so you resolve that, the file looks handled, and the silent deletion rides along. Reordering does not help; the report's `git merge-tree` runs both directions produce identical key loss. + +**What catches it:** `lib/i18n.types.ts` types `t()` against the locale JSON, so a referenced-but-missing key is a `TS2345` **compile error** — but only on a run that actually reflects the post-rebase tree. + +**Exact check — for whichever of `#356`/`#360` lands second:** +1. `git fetch origin && git rebase origin/main`, `git push --force-with-lease`. +2. On the rebased branch: `npx tsc --noEmit` — confirm **no `TS2345` on `connect.step*`**. If red, re-add `connect.step1`/`step2` (byte-identical to `main`'s originals) to **all four** `locales/{ar,en,he,ru}/onboarding.json` as part of the rebase, then re-run. +3. `grep -l 'connect.step1' locales/{ar,en,he,ru}/onboarding.json` — expect all four. +4. Merge **only** on the resulting fresh green. Conflicts **C** (`#357`'s `removeMessage`) and **G** (`#362`'s locale keys) are the same class but surface as *real* markers, so they are less dangerous — still re-check by usage. + +### `[skip-ci]` vacuous-green (repo-wide, feeds every phase) + +`test.yml`'s gate skips the heavy step when `[skip-ci]` is in the head commit / PR title / body, and the required contexts (`Type check`, `Unit tests`, `Integration tests`, `Lint`) still report **green in seconds having run nothing.** For Phase C this is lethal: the whole F defence rests on `Type check` *actually running*. **Before trusting green, confirm the fresh run executed** — a real `Type check` takes minutes, not seconds; if the landing commit carries `[skip-ci]`, the green is meaningless. + +### A — `#354 × #341` on `api-client.ts`, resolution no longer shortcuttable + +Both rewrote the same `!response.ok` block: `#341` added `warmupState`; `#354` hoisted `errBody` and added a 409 `ConversationBusyError` branch. Run 2 resolved it by **taking the file from `#355`'s tip**, which already held the union — **that shortcut dies when `#355` is closed.** Landing `#354` after `#341` is on `main`, you must **build the union by hand**: keep `#341`'s `warmupState`, keep `#354`'s hoisted `errBody` and the 409 branch. The report's precondition ("pre-merge `api-client.ts` byte-identical to `#355`'s base") no longer applies — re-read both sides. + +*After merging `#354`, open the `!response.ok` block and confirm **both** `warmupState` and the 409 `ConversationBusyError` branch are present.* + +### `#354 × #346` — external-gate suite navigation + +`SessionScreen.externalGate` is new in `#354` and its local `expo-router` mock omits `useNavigation`; the screen only calls `useNavigation` once `#346` is present. Neither PR is wrong alone. Fixed on `#354` (`25c83b6`) — so this only bites if that fix is missing. + +*After both `#346` and `#354` are on `main`, run the suite in isolation: `npx jest --ci --runInBand --testPathPattern "externalGate"`.* + +## Stacked pairs + +Only one: **`#341` is based on `#339`** (`baseRefName: feat/cache-integrity-alert`, **identify from the base ref, never the PR number**). `#355`'s base is the old snapshot branch, but `#355` is being closed, so it is not a stacking concern. + +Two different `--onto` rebases, not interchangeable: + +- **Whenever the parent is force-pushed or gains a commit**, the child is left on orphaned commits — replay onto the parent's new tip: + `git rebase --onto origin/feat/cache-integrity-alert feat/cache-warmup-status` + (`` is the last of the child's orphaned copies of the parent's commits, found via `git merge-base` — not the parent's *current* tip.) +- **After the parent squash-merges**, GitHub retargets the child to `main`, but the branch still carries the parent's individual commits, which the squash collapsed into one commit git cannot match against the originals — replay only the child's own work: + `git rebase --onto main feat/cache-integrity-alert feat/cache-warmup-status` + +**This repo gives stacked PRs full CI — unlike some.** `test.yml`'s `pull_request` trigger has **no `branches:` filter**, so it fires for a PR targeting *any* base branch. Verified 2026-07-23: `#341` (base `feat/cache-integrity-alert`) shows `Type check`, `Unit tests`, `Integration tests`, `Lint` all `SUCCESS`. So step 6 of the loop **can** be satisfied for `#341` — do not assume a stacked PR is un-CI'd here. (If `test.yml` ever gains a base-branch filter, this reverts and stacked PRs would need local verification instead.) + +## Moving target + +Anything prepared in advance can go stale before the squash: + +- **`main` advances mid-run.** Run 2 watched `main` take two CI commits (`54f6f43`, `3219d6f`) while landing. Rebase as **step 1 of the merge**, not the day before. +- **New PRs appear.** `#373` opened *after* the report was cut. Re-run the pre-flight PR-number diff before starting, and again if a phase spans sessions. +- **Upstream fixes must still be on their branches.** The runbook's correctness depends on `#346 b84f18c`, `#354 25c83b6`/`5b26bf7`, `#362 ec5260f`/`4c6a275`, `#343 393b9ca` staying present. Re-check each before landing its PR: `git merge-base --is-ancestor origin/` (exit 0 = present). All six verified present 2026-07-23; a force-push that dropped one would silently reintroduce the defect it fixed. + +## Content that exists nowhere else + +Work that lives only in the integration branch and **cannot be landed by merging a PR**, because it is a consequence of *combining* branches — expect to re-derive it: + +- **The conflict A union in `api-client.ts`.** It exists as one resolved file on the snapshot (and on `#355`'s now-doomed tip); on `main` it must be hand-built when `#341` and `#354` meet. Verified this is genuine merge-glue, not a single-branch defect: neither `#341` nor `#354` fails `tsc` alone. +- **The `ROADMAP.md` `
` repair** (conflict D) — only needed once `#358`'s text meets `#343`'s `
` wrapper. +- **The re-added `connect.step*` keys** (trap F) — only needed once `#356`'s deletion meets `#360`'s usage. + +Everything the report once listed as merge-glue but which turned out to be a *source-branch* defect (e.g. a lint failure a branch has in isolation) belongs at the source, not here — before filing something as merge-only, confirm the source branch fails on it alone, or you send the next operator into a red build with no diagnosis. + +## One post-merge action that is not a merge + +**After `#368` lands, add `i18n` to the required status checks in branch protection.** `#368` adds the `test:i18n` script and an `i18n` CI job (it closes the gap where `i18n-completeness`/`i18n-unused-keys` never ran on a PR), but a new job that is not *required* runs without being able to block anything. Reading protection via the API on 2026-07-23 returned "Branch not protected" (likely a token-scope limit, not the real state) — confirm and set the required checks in the repo **Settings → Branches** UI, do not trust that API read. + +## Definition of done + +- All **20** PRs squash-merged to `main`; `#355` closed with its re-verified evidence recorded; `#291` untouched. +- `i18n` added to `main`'s required status checks (the post-merge action above). +- `main` green on a **fresh, non-`[skip-ci]`** run. +- `integration-dev/v1.0.0-2026-07-22` reduced to nothing but the report file — `git log --merges origin/main..HEAD` shows no unlanded PR merges. Any code still unique to the integration branch at that point is a change that was never landed. + +## Follow-up PRs — session-name display (separate chain) + +These were opened **after** the kick-off and are **not** part of the 20-PR chain above — different base, different set. They fix interactive Claude Code conversations carrying no session name (the scanner only read the `slug` field, which the human REPL never writes), so mobile showed the project name instead of a real title in the list, conversation, and live-session views. It is a **cross-repo** chain; only the mobile PR lands in *this* repo. + +| Order | Repo | PR | Branch | What it does | +|---|---|---|---|---| +| 1 | tb-scanner | [#53](https://github.com/RonenMars/threadbase-scanner/pull/53) | `fix/session-name-from-first-message` | Derive session name from the first user message when no `slug`. The data source. | +| 2 | tb-streamer | [#267](https://github.com/RonenMars/threadbase-streamer/pull/267) | `fix/emit-session-name` | Emit `session_name` in the conversation detail `meta` block. | +| 3 | **tb-mobile** | [#376](https://github.com/RonenMars/threadbase-mobile/pull/376) | `fix/session-name-display` | Read `session_name` in the list, conversation, and live-session views (user rename → session name → project name). **The only one that lands here.** | + +**`#376` is safe to land alone, whenever.** It is additive and targets `main` directly (verified 2026-07-23: `OPEN`, `MERGEABLE`/`CLEAN`). It shows nothing new until the server pipeline lands (scanner #53 → scanner release → streamer dep bump → streamer #267); until then the name simply stays blank, so there is no ordering constraint against the chain above — treat it as a Phase-A-style mechanical independent, just recorded separately because it is a different set. Include `#376` in the pre-flight sweep even though it is not in the count of 20. + +## Follow-up PRs — session load + cumulative slowdown (separate chain) + +Opened 2026-07-23, **after** the kick-off and **not** part of the 20-PR chain above — different set, all mobile-only, all cut from the integration branch. They came out of two diagnosed bugs: a live session parked on a question/permission card showed a blank terminal that "failed to load" (the WS `terminal_replay` came back blank and the client latched it as a successful load, disarming the HTTP `/output` fallback that held the full transcript), and the app slowed down progressively after opening 5-6 sessions (native-stack screens stay mounted, so each opened session kept its WS handlers firing and its `VirtualTerminal` grid growing). The three fixes are already committed on `integration-dev/v1.0.0-2026-07-22` (`83cfe3a`, `77e568b`, `4b25551`). + +| Order | PR | Branch → base | What it does | +|---|---|---|---| +| 1 | [#385](https://github.com/RonenMars/threadbase-mobile/pull/385) | `fix/terminal-empty-replay-fallback` → `main` | Ignore a blank `terminal_replay` so the 2s HTTP `/output` fallback stays armed and fills a card-parked session's terminal. Independent. | +| 2 | [#386](https://github.com/RonenMars/threadbase-mobile/pull/386) | `perf/freeze-hidden-session-screens` → `main` | `freezeOnBlur` on the session Stack so a pushed-under screen stops running effects/handlers while hidden. | +| 3 | [#387](https://github.com/RonenMars/threadbase-mobile/pull/387) | `perf/cap-virtualterminal-scrollback` → **`perf/freeze-hidden-session-screens`** | Cap the `VirtualTerminal` grid at 10k rows so memory and the per-frame `getLines()` scan stay bounded. **Stacked on #386.** | + +**Each is additive and mobile-only** — no server, API, or WS-contract change — and each carries the full integration history against `main`, as intended (verified 2026-07-23: all three `MERGEABLE`/`CLEAN`). +**#385 and #386 target `main` and are independent** of each other and of the chain above — land either order, Phase-A-style. +**#387 is stacked on #386** (its base is `#386`'s branch, so its diff shows only the `VirtualTerminal` change): merge #386 first, then rebase #387 `--onto main` before merging it — otherwise a squash of #387 would drag #386's commit onto `main` a second time. +Include all three in the pre-flight sweep even though they are not in the count of 20. + +## Local integration merge — PRs #354, #355, and #376 + +On 2026-07-23, the three PRs previously reported as missing were merged into the isolated worktree `/private/tmp/tb-mobile-merge-354-355-376`, based on integration tip `5502eb3`, in this order: **#354 → #355 → #376**. + +| PR | Local merge commit | Result | +|---|---|---| +| #354 | `a70110d` | Merge commit created; the substantive live-session code was already represented in the integration tree through equivalent content. | +| #355 | `1ee5e4e` | Added the three missing `takeOver` translation blocks in `locales/{ar,he,ru}/conversation.json`; its `app/conversation/[id].tsx` conflict retained the integration branch's in-chat search UI. | +| #376 | `6840078` | Merge commit created; the session-name display code was already represented in the integration tree through equivalent content. | + +These are local preparation merges only. They were not pushed or squash-merged to `main`; the worktree remains the place to run focused verification before any outward-facing merge. diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md new file mode 100644 index 00000000..581c9107 --- /dev/null +++ b/docs/runbooks/README.md @@ -0,0 +1,15 @@ +# Runbooks + +Executable procedures. A runbook tells you how to carry out work that is risky, sequential, or easy to get subtly wrong — and, critically, what failure looks like at each step. + +**A runbook is not a postmortem / merge report.** A report records what happened at a point in time and stops changing once written; a runbook is a living document, edited every time reality moves. A runbook should link to its report for the *why* rather than restating it — two copies of the same analysis drift. + +## Files here + +| File | What it is | +|---|---| +| [`_template.md`](_template.md) | The format. Copy it to `YYYY-MM-DD-.md` and fill it in. It opens with the seven rules its sections encode. | +| [`_example-streamer-land-open-prs.md`](_example-streamer-land-open-prs.md) | A **reference example** — the same template filled in for the *streamer* repo's PR-landing chain. Use it to see the shape of a completed runbook; its PR numbers and paths are streamer-specific and do not apply here. | +| [`2026-07-22-land-open-prs.md`](2026-07-22-land-open-prs.md) | The mobile PR-landing runbook, derived from [`../integration-merge-report-2026-07-22.md`](../integration-merge-report-2026-07-22.md). | + +The `_`-prefix marks files that are not themselves runbooks (the template and the cross-repo example), so a directory listing separates them from real, dated runbooks. diff --git a/docs/runbooks/_example-streamer-land-open-prs.md b/docs/runbooks/_example-streamer-land-open-prs.md new file mode 100644 index 00000000..f27a14e0 --- /dev/null +++ b/docs/runbooks/_example-streamer-land-open-prs.md @@ -0,0 +1,169 @@ +# Landing runbook — getting these PRs onto `main` + +**Source:** [docs/postmortems/2026-07-22-merge-all-open-prs-report.md](../postmortems/2026-07-22-merge-all-open-prs-report.md). References below to "Part 1", "C1–C6" and "S1" are sections of that report. +**Status:** live procedure — edit it as PRs land. + +The integration branch proves the PRs *can* coexist. It does not land them. Each still goes onto `main` one at a time under the repo's rebase + squash rule, and **the conflicts documented in Part 1 will recur** — this document maps them to the order so nobody meets them cold. + +## Who should run this + +Not uniform across the work: two of the five groups are mechanical, and two contain traps that are invisible unless the operator reads code that git reported as clean. + +| Groups | Character of the work | Model | Effort | +|---|---|---|---| +| 1, 2, 5 — independents, dependabot, docs | Rebase, check CI, squash. No documented conflicts. The docs group has one predictable textual conflict in `BACKLOG.md`. | **Sonnet 5** (`claude-sonnet-5`) | **medium** | +| 3, 4 — the cache cluster and the live-sessions pair | Six documented conflicts, two silent-drop traps, one stacked-pair `--onto` rebase, and resolutions where the *ordering* of two blocks is a correctness decision. | **Opus 4.8** (`claude-opus-4-8`) | **high**, raised to **xhigh** for the two trap merges (#237, #253) | + +**On effort.** Not `low` even for Group 1: the cheap groups still end in an irreversible squash to `main`, and `low` is where "CI is green, ship it" wins over checking *which commit* CI tested — a mistake this session made once already. Not `max` anywhere either; the hard part here is noticing that something is absent, which more deliberation per step does not buy. The one place to spend extra is the two merges where git reports success and code has gone missing — that is a "read this specific method and compare against a list" task, and `xhigh` buys the patience to actually do it rather than assume. + +The model split is not about difficulty of the git commands — it is about what failure looks like. In Groups 3 and 4 the dangerous outcome is a **clean auto-merge, a green `tsc`, and silently deleted safety code**. Nothing prompts the operator to look; they have to already know to open the extracted method and check. That judgment, plus resisting the pull to accept a green signal, is where the stronger model earns its cost. + +Do not use Haiku for any group. Every group ends in a squash-merge to `main`, and steps 3, 4 and 6 require deciding whether a green signal is trustworthy — this session produced three separate misattributed "flakes" and two wrong root-cause calls before the evidence was checked properly. + +**Operating constraints for whoever runs it, human or model:** + +- **One PR per session.** Context exhaustion mid-merge is how a resolution gets half-applied. Groups 3 and 4 in particular deserve a fresh context per PR. +- **Never parallelise.** Each merge advances `main` and stales the next; two in flight guarantees a stale rebase. +- **Verify, do not infer.** The rules that repeatedly mattered here: a blocked compound shell command runs *none* of its parts; a negative `grep` may mean the wrong working directory; a 5× jump in failures is an environment signal, not a code signal. + +## Before starting — decide these once + +| PR | Decision | +|---|---| +| **#223** `typescript-7.0.2` | **Excluded.** Standing instruction, carried from the 2026-07-20 round. | +| **#251** `chore/verify-open-prs-merge` | **Excluded and closeable.** It *is* the previous verification branch; `integration-dev/v1.0.0-2026-07-22` supersedes it. | +| **#245** `fix/grace-timer-flake` | **Close, do not merge.** Fully superseded by `171ee42` on `main` (C5). Optionally salvage one line first: #245's `waitFor` throws on timeout, `main`'s silently returns, so a real timeout on `main` surfaces as a confusing downstream assertion instead of naming its cause. | + +That leaves **18 PRs to land**. + +## Pre-flight — run this before landing anything + +Sweep every open PR for mergeability and red checks first. Two blockers were sitting in the set when it looked ready, and neither was visible from the integration branch being green: + +``` +gh pr list --state open --limit 50 --json number,headRefName -q '.[]|"\(.number) \(.headRefName)"' | + sort -n | while read n b; do + gh pr view $n --json mergeable,mergeStateStatus,statusCheckRollup \ + -q '"\(.mergeable)|\(.mergeStateStatus)|\([.statusCheckRollup[]?|select(.conclusion=="FAILURE")|.name]|join(","))"' + done +``` + +**Run it twice.** GitHub computes mergeability lazily; the first `gh pr view` only *triggers* the computation and returns `UNKNOWN`. A bulk `gh pr list --json mergeable` never resolves at all. This is why the first sweep reported `UNKNOWN` for 13 PRs and the second returned real values. + +Expect most PRs to read `BEHIND` — that is normal and is handled by step 1 of the loop. What you are looking for is `CONFLICTING` / `DIRTY`, or a non-empty failing-checks column. + +Found this way, 2026-07-22: **#234** `CONFLICTING` (orphaned stacked base — see "Stacked pairs") and **#259** failing `Lint` on `cli/prod.ts format` (its own defect, fixed at source in `801b80f`). `#245` also reads `CONFLICTING`, which is moot — it is being closed. + +## The per-PR loop + +Run this for one PR at a time. Never two in parallel — each merge advances `main` and stales the next. + +1. `git fetch origin && git rebase origin/main` on the PR branch. **Rebase immediately before merging, not in advance** — see "Moving target" below. +2. Resolve conflicts using the matching entry in Part 1. Do not improvise a resolution that Part 1 already documents. +3. If the PR extracted code into a method, or the other side did, **read the extracted method** before continuing. See "The two silent-drop traps". +4. `npx tsc --noEmit && npm run lint`, then the affected suites. +5. `git push --force-with-lease` (never plain `--force`). +6. Wait for CI green. If red on a flake, re-run **once**; if still red, stop and report. Check what else is running on the box before calling anything a flake. +7. `gh pr merge --squash --delete-branch`. Conventional title, no AI attribution. +8. If the merged PR was the base of a stacked PR, immediately `--onto` rebase the child (see "Stacked pairs"). + +## Stop and wait for approval + +If Claude Code is running this, these are hard stops. Present the evidence and wait — do not proceed on inference, and do not batch several past a stop. + +**Always stop before:** + +1. **Any squash-merge to `main`** (step 7). It is the only irreversible step in the loop and it changes a shared branch. Show: the PR number, the squash title, CI state, and — for Groups 3 and 4 — the result of the trap check from step 3. +2. **Any commit.** Repo rule, no exceptions: show `git diff --staged` and the exact message, then wait. This holds even when the instruction was "just commit it". +3. **Any force-push** (step 5), including `--force-with-lease`. State what is being rewritten and why the rewrite is safe. Never plain `--force`; never force-push `main`. +4. **Closing a PR** (#245, #251). Closing is a judgment about someone's work, not a mechanical step. + +**Stop and ask when the situation is not the one this runbook describes:** + +5. **A conflict that Part 1 does not document.** The documented resolutions were reasoned once and verified; a novel conflict means reality diverged from this plan. Report the files and hunks rather than improvising. +6. **A trap check that comes back missing** — `withWarmup`, the pending-alert freeze, or `canonicalLivePathSet` absent after the merge. That means the resolution was wrong, and the correct fix may not be "add it back". +7. **CI red after exactly one re-run.** One re-run for a suspected flake, then stop. Before calling anything a flake, check what else is running on the machine — concurrent test suites on this box produced three misattributed flakes in one session. +8. **Pre-flight finds a blocker not listed here** (anything beyond #234's orphaned base and #259's `Lint`). A new `CONFLICTING` or a new red check means the branch set moved. +9. **A verification claim that cannot be proven mechanically.** If you cannot show that a merge is contained or a resolution is lossless — no tree-hash match, no clean reverse-apply — say so plainly rather than asserting it. Three separate containment checks gave wrong answers in this session; the honest report is "I could not verify this", not a confident yes. +10. **The suite regresses by a large multiple.** A 5× jump after a conflict-free rebase is an environment fault, not the diff. Diagnose and report before touching code. + +**Do not stop for these** — they are reversible and asking for each one turns an 18-PR sequence into an interrogation: fetching, rebasing, resolving a conflict that Part 1 documents, running tests or lint, reading files, creating a worktree, or copying `node_modules`. + +## Order + +Grouped by risk, not by number. Within a group the order does not matter. + +**Group 1 — land first, independent and green.** + +`#260` `test/isolate-scanner-fixtures` — **do this one first.** It takes `main` from 35 failing tests to 7 on its own, which is what makes every later PR's local verification trustworthy. Nothing depends on it and it touches only test files. + +Then `#255`, `#240`, `#241`, `#252`, `#258`, `#259`. All independent, no documented conflicts. + +**Group 2 — dependabot.** `#224`, `#226`, `#227`, `#264`. Lockfile-only; dependabot rebases them itself. Land whenever; they will need no manual conflict work. + +**Group 3 — the cache cluster, in this exact order.** This is where every documented conflict lives. + +1. `#232` `feat/cache-integrity-alert` — clean onto `main`. +2. `#234` `feat/cache-warmup-status` — **stacked on #232** (`baseRefName`), never merge it first. Expect C1, C2, C3: 3 files, 10 hunks. C1's whole-file resolution is only safe because the file is new in #232 — re-verify that before using `--theirs`. +3. `#237` `fix/stale-conversation-history` — expect C4, 3 hunks, **plus trap S1 below**. + +**Group 4 — the live-sessions pair.** + +4. `#253` `feat/live-external-sessions` — expect C6, 3 hunks. One is an ordering decision, not a formatting one: `detachExternalTail` must run **before** the pending-alert freeze's early `return`, or the watcher teardown is silently skipped whenever an alert is pending. +5. `#254` `fix/adopt-resolve-cwd-from-jsonl` — **stacked on #253**; see "Stacked pairs". + +**Group 5 — docs last.** + +`#242` and `#257`. Every product PR edits `docs/BACKLOG.md`, and #257 rewrites item statuses wholesale. Landing it last means resolving that file once against a settled `main` instead of on every merge. Expect the same semantic conflict seen here: a PR's own entry says "Fixed" while #257's table says "In flight". Once the PR is on `main`, "Fixed" is the true statement. + +## The two silent-drop traps + +Both produce a **clean auto-merge with no conflict marker**, a green `tsc`, and quietly deleted safety code. Both have already happened, one of them twice. + +**Trap 1 — merging #237.** #237 extracts the refresh logic into `reconcileConversationsCacheFromDisk()`. Because that method is new code, git merges it cleanly — and it does **not** inherit the protections already on `main` from #232 and #234: `withWarmup("conversation_refresh", ...)` around `rescanForRefresh()`, and the pending-alert freeze around `reconcileDeletions()`. #237 also makes that path fire automatically on `scannerStale` and freshness drift, so the unprotected version runs far more often than the protected one ever did. + +*After merging #237, open `reconcileConversationsCacheFromDisk()` and confirm both are present.* + +**Trap 2 — merging #253 after #237.** The same shape, opposite direction. #253 still carries the **inline** version of that block; `main` will have #237's **extracted** method. Taking `main`'s side is correct and discards #253's `canonicalLivePathSet(metas)` fix with no marker. + +*After merging #253, confirm `reconcileConversationsCacheFromDisk()` calls `canonicalLivePathSet(metas)` and not a hand-rolled `new Set(metas.map(...))`.* + +The general rule: **after resolving any conflict where one side extracted code into a method, read the method.** Conflict markers only ever show the call site. + +## Stacked pairs + +`#232 → #234` and `#253 → #254`. Identify these from `baseRefName`, never from PR number. + +Two separate rebases are needed for a child, and they are not interchangeable: + +- **Whenever the parent is force-pushed** (a rebase, a new commit), the child is left on orphaned commits. Replay it onto the parent's new tip: + `git rebase --onto origin/ ` + This already bit us once: rebasing #253 orphaned #254's base, and #254 was missing #253's three newest commits until it was replayed. +- **After the parent squash-merges**, GitHub retargets the child to `main`, but the branch still carries the parent's individual commits, which the squash collapsed into one commit git cannot match. Replay only the child's own work: + `git rebase --onto main ` + +**Diagnosing an orphaned child.** #234 sat at `CONFLICTING` / `DIRTY` because #232 had been rebased at some earlier point and #234 still carried the *pre-rebase copies* of #232's two commits. The tell is the merge-base: `git merge-base ` pointed at `0b1b599`, far behind the parent's actual history, and `git merge-base --is-ancestor ` returned false for each copy. The `` argument to `--onto` is then the last of those orphaned copies, not the parent's current tip. Resolved with `git rebase --onto origin/feat/cache-integrity-alert 7d7e8a7 feat/cache-warmup-status`. + +A stacked PR also gets **no CI in this repo**: `ci.yml` triggers on `pull_request: branches: [main]`, so a PR based on a feature branch reports Snyk and nothing else until its base becomes `main`. Treat a green badge on one as meaningless — and note the consequence for the loop above: **step 6 cannot be satisfied for a stacked PR.** Verify it locally instead (`tsc`, lint, the suites its files touch), which means its worktree needs `node_modules`. Compare any failures against the pre-rebase tip before blaming the rebase; #234 showed 12 failures after its rebase and exactly the same 12 before it. + +**Re-merging a rebased branch into the integration branch replays its original conflicts**, because the rebase gave its commits new SHAs. If the branch's content did not change, resolve to HEAD and then prove it: `git write-tree` after resolving should equal `git rev-parse HEAD^{tree}` from before the merge. An identical hash means the merge added nothing and the resolution was correct — cheaper and more reliable than re-reading every hunk. + +## Moving target + +`main` took five auto-merged scanner bumps in a few hours (`0.11.1` → `0.11.5`). Any branch rebased more than a few minutes ahead of its merge will be behind again. Rebase as step 1 of the merge, not as preparation the day before. + +These bumps are lockfile-only and conflict with nothing, so being behind is cheap — but branch protection may still block the merge until it is resolved. + +## Content that exists in no PR + +The integration branch carries changes that belong to no individual PR and therefore **cannot be landed by merging PRs**. They are consequences of combining the branches: + +- `b972dcd` — import order in `src/server.ts`, only wrong once #232's and #237's import blocks are combined. Biome sorts `./services/cache/cacheMetadata` **before** `./services/cache-integrity/cacheIntegrityMonitor`; the 2026-07-20 report's rule for this is wrong. +- ~~`e2ac107` — formatting in `cli/prod.ts` after #259's edits meet the existing ones.~~ **Wrong — this was #259's own defect, not merge glue.** Biome reports #259's copy of `cli/prod.ts` unformatted in isolation on that branch, and its `Lint` job was already red for it. Fixed at source on #259 (`801b80f`). Listing it here would have sent whoever lands #259 into a red Lint job with no idea it had been diagnosed. **Before filing something as merge-only glue, check whether the source branch fails on it alone.** +- Six merge commits carrying conflict resolutions (9–80 lines each), the largest being #237's. + +Expect to re-derive equivalents while landing. If `npm run lint` fails after a merge with nothing obviously wrong, this is why — run `npx biome check --write ` on the file the merge touched. + +## Definition of done + +`main` green, and `integration-dev/v1.0.0-2026-07-22` reduced to nothing but the postmortem file. Any code still unique to the integration branch at that point is a change that was never landed. diff --git a/docs/runbooks/_template.md b/docs/runbooks/_template.md new file mode 100644 index 00000000..280b53d9 --- /dev/null +++ b/docs/runbooks/_template.md @@ -0,0 +1,88 @@ +# Runbook template + +Copy this to `docs/runbooks/YYYY-MM-DD-.md` and fill it in. Delete any section that genuinely does not apply — an empty heading is worse than no heading. + +A runbook is **not** a postmortem. A postmortem records what happened at a point in time and should stop changing once written; a runbook is an executable procedure with a lifetime, edited every time reality moves. Keep them in separate files, and have the runbook link back to the postmortem for the *why* rather than restating it. + +## What makes these useful (the rules the sections below encode) + +1. **State what failure looks like, not just what to do.** A step that says "resolve the conflict" is worthless next to one that says "this merge is clean and silently drops the freeze — open the method and check." +2. **Anything already decided goes at the top.** Exclusions, closures and "do not merge this" belong in one table, not scattered across the body. +3. **Order by risk, not by identifier.** Group the mechanical work together and the dangerous work together; say which must go first and why. +4. **Point at existing analysis, do not restate it.** Link the postmortem section that documents each conflict. Restating invites the two copies to drift. +5. **Record the traps that produce green signals.** Those are the only ones a careful operator still walks into. +6. **Say who should run it.** Match the operator — model and reasoning effort — to the failure mode of each phase, not to the apparent difficulty. +7. **Name the stop points, and the non-stop points.** A runbook an agent can execute needs both: where it must hand back control, and the reversible majority where asking would just be noise. A stop list without a do-not-stop list gets ignored wholesale. + +--- + +# — <one line on what this gets you> + +**Source:** link to the postmortem/analysis this derives from. Note the naming used for cross-references. +**Status:** live procedure — edit it as the work proceeds. + +<One paragraph: what the preceding work established, what this document does that the preceding work did not, and the single most important thing to know before starting.> + +## Who should run this + +<Only split by phase if the phases genuinely differ in failure mode. If uniform, say so in a sentence.> + +| Phase | Character of the work | Model | Effort | +|---|---|---|---| +| | | | | + +<Explain the split in terms of what failure looks like, not command difficulty. Name any model that should not be used, and why. Include a **reasoning effort** per phase, and justify both ends: why not lower (what gets skipped when the operator is in a hurry) and why not higher (what extra deliberation would not buy). Effort is worth raising specifically where the failure is *absence* — something missing that no error reports.> + +**Operating constraints:** + +- <Context/session boundaries — where exhaustion mid-step would corrupt state.> +- <Serialisation requirements — what must never run in parallel, and what breaks if it does.> +- <Verification discipline specific to this task.> + +## Before starting — decide these once + +| Item | Decision | +|---|---| +| | **Excluded / Close / Land.** Reason, plus anything worth salvaging first. | + +That leaves **N items**. + +## Pre-flight + +<A concrete command that sweeps for blockers, plus how to read its output: which states are normal and handled later, and which are genuine blockers. Record any tooling quirk that makes the output lie — lazy computation, caching, needing a second run.> + +Found this way, <date>: <list what the sweep actually caught, so the next reader knows it earns its keep.> + +## The per-item loop + +<Numbered, one item at a time. Include the verification gate and what to do when it is red. Flag any step that cannot be satisfied for a subset of items, and what to substitute.> + +## Stop and wait for approval + +<Hard stops for an agent running this unattended. Two lists, both necessary:> + +<**Always stop before** — irreversible or outward-facing actions: writes to shared branches, commits, force-pushes, closing others' work, anything published. For each, say what evidence to present at the stop.> + +<**Stop and ask when the situation is not the one this document describes** — a conflict not documented, a check that comes back wrong, CI red after one re-run, a blocker the pre-flight did not predict, or a claim that cannot be proven mechanically. These are the stops that matter most: the first list is obvious, this one is what stops an agent confidently doing the wrong thing.> + +<**Do not stop for these** — the reversible majority. Naming them explicitly is what keeps the stop list credible; an agent that asks about everything gets waved through on everything.> + +## Order + +<Grouped by risk. For each group: which items, why they are grouped, which must go first. Name the specific conflicts each will hit and link the analysis.> + +## Known traps + +<One subsection per trap that produces a *green* signal. For each: the mechanism, why it is invisible, and the exact check to run afterwards. This is the highest-value section — a trap that announces itself does not need a runbook.> + +## Moving target + +<Anything that invalidates preparation done in advance: an auto-updating dependency, a branch that advances, an external service. Say how far ahead preparation stays valid.> + +## Content that exists nowhere else + +<Work that lives only in the intermediate artefact and will have to be re-derived. Before listing something here, verify the source fails on it alone — otherwise it belongs at the source and listing it here sends the next operator into a red build with no diagnosis.> + +## Definition of done + +<An observable end state, not "all items processed". Ideally one that makes leftovers self-evident.> diff --git a/docs/store-console-wording.md b/docs/store-console-wording.md index 32f9b8b2..2cbbc89d 100644 --- a/docs/store-console-wording.md +++ b/docs/store-console-wording.md @@ -25,15 +25,17 @@ field in both consoles must point to the updated page before submitting. **Description field (free text, if offered):** > Threadbase is a thin client for self-hosted Threadbase streamers — it does -> not run its own analytics or telemetry. Crash data is sent to Sentry, a -> third-party crash-reporting service, only in two cases: (1) automatically, if -> you turn on "Share anonymous crash reports" in Settings (off by default), or -> (2) as a one-time report if you tap "Report this crash" on the error-recovery -> screen after a crash, which works even if automatic reporting is off. Crash -> data is limited to app version, platform, OS version, and a sanitized error -> type/message/stack trace, plus a random installation identifier used only to -> group reports. It never includes prompts, terminal output, source code, -> credentials, server addresses, or session content. +> not run its own analytics or telemetry. Technical data is sent to Sentry, a +> third-party crash-reporting service, only in user-initiated cases: +> (1) automatically, if you turn on "Share anonymous crash reports" in Settings +> (off by default); (2) as a one-time report if you tap "Report this crash" on +> the error-recovery screen after a crash; or (3) when you submit feedback via +> the Help & Feedback screen. Cases (2) and (3) work even if automatic reporting +> is off — tapping "Send" or "Report" is treated as explicit consent for that +> single submission. Data is limited to app version, platform, OS version, and a +> sanitized error type/message/stack trace, plus a random installation +> identifier used only to group reports. It never includes prompts, terminal +> output, source code, credentials, server addresses, or session content. ### Data type: Diagnostics → Performance Data **Not collected.** (Performance tracing is disabled in the app.) @@ -104,14 +106,16 @@ cross-app or cross-site tracking, advertising, or data broker sharing. **Why this data is collected (free text):** > Threadbase is a thin client for self-hosted Threadbase streamers and runs no -> analytics or telemetry of its own. Crash logs are sent to Sentry, a -> third-party crash-reporting processor, only when you turn on "Share -> anonymous crash reports" in Settings (off by default), or when you tap -> "Report this crash" to manually send a single report after a crash — the -> manual option works even with automatic reporting off. Logs contain -> sanitized technical details only (app version, platform, OS version, error -> type/message/stack trace) and a random installation id for grouping; they -> never contain prompts, terminal output, source code, credentials, server +> analytics or telemetry of its own. Technical data is sent to Sentry, a +> third-party crash-reporting processor, only in user-initiated cases: (1) when +> you turn on "Share anonymous crash reports" in Settings (off by default); +> (2) when you tap "Report this crash" to manually send a single report after a +> crash; or (3) when you submit feedback via the Help & Feedback screen. Cases +> (2) and (3) work even with automatic reporting off — tapping "Send" or +> "Report" is treated as explicit consent for that single submission. Logs +> contain sanitized technical details only (app version, platform, OS version, +> error type/message/stack trace) and a random installation id for grouping; +> they never contain prompts, terminal output, source code, credentials, server > addresses, or session content. ### Data type: App info and performance → Diagnostics diff --git a/docs/store-privacy-checklist.md b/docs/store-privacy-checklist.md index 4a098699..af41dcd9 100644 --- a/docs/store-privacy-checklist.md +++ b/docs/store-privacy-checklist.md @@ -82,12 +82,14 @@ update the form: ## Cross-cutting reminders - [ ] Both stores: **automatic** crash reporting must be described as optional / - off by default, matching the in-app toggle. Additionally disclose that a - **single crash report can still be sent manually** via an explicit - "Report this crash" tap on the crash screen, independent of that toggle — - this is a distinct, user-initiated action, not automatic collection, but - it means "crash data is never sent while the setting is off" would be an - inaccurate description and must not be used. + off by default, matching the in-app toggle. Additionally disclose that + **user-initiated submissions can still use Sentry** independent of that + toggle — this includes both the "Report this crash" tap on the crash + screen and the Help & Feedback submission (both are explicit, deliberate + actions the user takes, not automatic collection). This means "crash data + is never sent while the setting is off" would be an inaccurate description + and must not be used; the accurate framing is "automatic reporting is off + by default; user-initiated submissions may still use Sentry". - [ ] Both stores: do **not** declare advertising, tracking, or fingerprinting — the app does none. - [ ] After a manual report is sent, the app may show a one-time prompt asking diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4c38bb8a..a8d4dc31 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -137,6 +137,57 @@ even though the package is listed in `package.json` and `package-lock.json`. --- +## Jest test suites + +### `SessionScreen.*` suites fail in a batch but pass one at a time + +**When:** Running the integration suites together — `npx jest`, `npm run test:integration`, or `npx jest --testPathPattern "SessionScreen"` — several `SessionScreen.*` suites fail, but re-running any one of them alone passes. The reported errors are often unrelated to each other and shift between runs. + +**Cause:** Those suites each render the full session screen with fake timers, WebSocket stubs and long backstop timeouts, so they are heavy. Jest's default parallel workers oversubscribe the machine and the slower workers miss timing-dependent assertions. This is environmental, not a defect in the code under test. + +**Fix:** Verify them serially: + +```bash +npx jest --ci --runInBand --testPathPattern "SessionScreen" +``` + +**The trap runs both ways — do not use this to wave failures away.** A batch failure can be a load artifact *or* a real defect, and they look identical in the batch output. Always confirm by re-running the single suite in isolation: + +- Passes alone → load artifact, ignore it. +- Fails alone → real defect, fix it. + +During the 2026-07-22 integration snapshot, four genuinely broken suites were nearly dismissed as flakes on this basis, while two others really were load artifacts. Only the isolation re-run distinguished them. + +### Suites known to be load-sensitive + +These pass in isolation and on a green full run, but can fail under a loaded parallel run. Re-check in isolation before investigating: + +| Suite | Typical symptom | +|---|---| +| `__tests__/e2e/feedback-flow.test.tsx` | exceeds the 5 s per-test timeout (also fails on `main`, Windows only) | +| `__tests__/unit/components/servers/CacheAlertModal.test.tsx` | assertion timeouts | +| `__tests__/integration/conversation-live-view.test.tsx` | streamed-event assertions | +| `__tests__/integration/conversation-detail-gating.test.tsx` | render timeouts | +| `__tests__/integration/conversation-search-anchor.test.tsx` | render timeouts | + +### `npx jest` reports "No tests found" in a git worktree + +**When:** A worktree created under `.claude/` (e.g. `.claude/worktrees/<name>`) reports `No tests found` with `0 matches`, and scripts like `npm run test:i18n` look broken even though they work in the main checkout. + +**Cause:** `jest.config`'s `testPathIgnorePatterns` excludes `\\.claude\\`, so every test path inside such a worktree is filtered out. Nothing is wrong with the test or the script. + +**Fix:** Create worktrees **outside** `.claude/` — e.g. `worktrees/<name>` alongside the repo. To run in place anyway, override the ignore list: + +```bash +npx jest --testPathIgnorePatterns "/node_modules/" +``` + +Note that the override also re-enables `__tests__/unit/scripts/`, which the main config excludes deliberately (it runs under `jest.config.scripts.js` via `npm run test:scripts`) and which fails on Windows. Ignore those failures. + +Each worktree needs its own `npm ci` — jest resolves modules from the worktree root, not the main checkout. + +--- + ## iOS Simulator console noise ### `CHHapticPattern` / `CHHapticEngine` "hapticpatternlibrary.plist" errors flooding the log diff --git a/e2e/browse.yaml b/e2e/browse.yaml index ad4b9381..63d88e6b 100644 --- a/e2e/browse.yaml +++ b/e2e/browse.yaml @@ -20,11 +20,19 @@ appId: com.ronenmars.threadbase - assertVisible: id: "hub-screen" +- assertVisible: + id: "fab-new-session" + # Filter & sort sheet opens - tapOn: id: "filter-sort-button" -- assertVisible: - id: "filter-sort-sheet" +# Wait for the sheet content testID (BottomSheetScrollView). Prefer +# extendedWaitUntil over a bare assert so a slow sheet animation is not +# misreported as an app crash by the XCUITest driver. +- extendedWaitUntil: + visible: + id: "filter-sort-sheet" + timeout: 5000 - takeScreenshot: e2e/_artifacts/screenshots/browse-01-filter-sort-sheet diff --git a/e2e/ensure-release-build.js b/e2e/ensure-release-build.js index c7f04b15..27eda5ef 100755 --- a/e2e/ensure-release-build.js +++ b/e2e/ensure-release-build.js @@ -69,3 +69,21 @@ if (installedBuildType !== 'Release') { console.log('Release build already installed. Launching...') execFileSync('xcrun', ['simctl', 'launch', 'booted', BUNDLE_ID], { stdio: 'inherit' }) } + +// Voice dictation Maestro flow asserts `chat-mic-button`, which only mounts +// after speech-recognition permission is granted. Fresh/erase'd sims have none. +function grantSpeechRecognition() { + const services = ['speech-recognition', 'microphone'] + for (const service of services) { + try { + execFileSync('xcrun', ['simctl', 'privacy', 'booted', 'grant', service, BUNDLE_ID], { + stdio: 'inherit', + }) + console.log(`Granted ${service} to ${BUNDLE_ID}`) + } catch { + console.warn(`Could not grant ${service} (simctl may use a different service name)`) + } + } +} + +grantSpeechRecognition() diff --git a/e2e/feat1_tree_drill_new_session.yaml b/e2e/feat1_tree_drill_new_session.yaml index f38847ad..fac28da2 100644 --- a/e2e/feat1_tree_drill_new_session.yaml +++ b/e2e/feat1_tree_drill_new_session.yaml @@ -1,9 +1,10 @@ # Maestro E2E — Feature 1: tree drill pre-fills new-session cwd # Flow: -# 1. Land on the hub (TreeView is the default sessionsLayout). -# 2. Drill into a project leaf row. -# 3. Tap the FAB to start a new session. -# 4. Assert the browse screen opened with cwd pre-filled to the drilled path. +# 1. Land on the hub (Classic is the default sessionsLayout). +# 2. Switch to Tree via FilterSortSheet (self-sufficient — no Settings visit). +# 3. Drill into a project leaf row. +# 4. Tap the FAB to start a new session. +# 5. Assert the browse screen opened with cwd pre-filled to the drilled path. # # Without this feature the FAB pushes /browse?server=<id> with no path, and # the breadcrumb would render only `~`. With the feature the breadcrumb @@ -16,15 +17,34 @@ appId: com.ronenmars.threadbase --- - runFlow: setup.yaml -# Hub chrome is mounted (TreeView is the default layout). - assertVisible: id: "hub-screen" +# Classic is default; switch to Tree so drill testIDs mount. +- tapOn: + id: "filter-sort-button" + +- extendedWaitUntil: + visible: + id: "filter-sort-sheet" + timeout: 5000 + +- tapOn: + id: "layout-option-tree" + +- tapOn: + id: "filter-sort-close-btn" + # Drill into the my-project leaf row in the tree. Fixture has three sessions # under /home/user/my-project so it is rendered as a leaf in the compacted # tree. We target by testID because the LIVE strip above the tree renders # additional rows whose project header also reads "my-project" — a plain # text match would be ambiguous. +- extendedWaitUntil: + visible: + id: "tree-row-/home/user/my-project" + timeout: 8000 + - tapOn: id: "tree-row-/home/user/my-project" diff --git a/e2e/server_drag_reorder.yaml b/e2e/server_drag_reorder.yaml index 618d281b..e108e17b 100644 --- a/e2e/server_drag_reorder.yaml +++ b/e2e/server_drag_reorder.yaml @@ -1,18 +1,18 @@ # Maestro E2E — Server drag-reorder flow # -# Verifies the drag-to-reorder feature in the Servers section of the -# Filter & Sort sheet (Edit-order lock toggle + DraggableFlatList). -# -# Two-mode flow: -# - If 2+ servers are paired, exercises the full reorder drill: -# open sheet → toggle edit-order → dragAndDrop → close → reopen → assert. +# Verifies the Filter & Sort sheet's server-order lock toggle gating: +# - If 2+ servers are paired, the toggle is visible; entering edit-order +# mode is exercised (toggle on → screenshot → toggle off). No swipe. # - If only 1 server is paired (the default after setup.yaml), asserts -# the `server-order-toggle` button is correctly hidden (the toggle is -# gated on `activeServerIds.length >= 2`). +# the `server-order-toggle` button is correctly hidden (gated on +# `activeServerIds.length >= 2`). +# +# Deliberately does NOT Maestro-`swipe` a drag handle. That step crashed +# the app on NestableDraggableFlatList (Bug 18). Row order after a real +# drag is covered by `__tests__/integration` for DisplayedServersList. # # To force the multi-server branch locally, pair a second server via -# Settings → Manage servers before running this flow, or seed the simulator -# Keychain with two entries for `threadbase_servers`. +# Settings → Manage servers before running this flow. # # Requires: mock server running on localhost:7071 # Usage: maestro test e2e/server_drag_reorder.yaml @@ -29,8 +29,10 @@ appId: com.ronenmars.threadbase - tapOn: id: "filter-sort-button" -- assertVisible: - id: "filter-sort-sheet" +- extendedWaitUntil: + visible: + id: "filter-sort-sheet" + timeout: 5000 # ── Multi-server branch ──────────────────────────────────────────────────── # Runs only when at least 2 servers are paired (the lock toggle is visible). @@ -39,40 +41,17 @@ appId: com.ronenmars.threadbase visible: id: "server-order-toggle" commands: - # Capture the first two server rows' order via testID introspection. - # Maestro can't read testID strings into variables, so we assume the - # first two servers are `srv_a` and `srv_b` in this exact order — the - # standard fixture-seeded state. If your seed differs, adjust the IDs. - # Enter edit-order mode (lock toggle) - tapOn: id: "server-order-toggle" - # Drag handle visible for each row - - assertVisible: - id: "drag-handle-srv_a" - - - assertVisible: - id: "drag-handle-srv_b" - - # Toggles are hidden in edit mode - - assertNotVisible: - id: "server-toggle-srv_a" + # Give NestableDraggableFlatList a beat to mount edit-mode rows. + # Do NOT swipe a drag handle — that crashed the app (Bug 18). + - waitForAnimationToEnd: + timeout: 3000 - # Visual: edit-mode entered, drag handles + hidden toggles visible. - takeScreenshot: e2e/_artifacts/screenshots/drag-reorder-01-edit-mode.png - # Drag srv_a's row down past srv_b (swaps their order). - # Maestro 2.x swipe takes direction+element, not from/to elements. - - swipe: - direction: DOWN - from: - id: "drag-handle-srv_a" - duration: 800 - - # Visual: rows post-drag — verifies the drop actually animated. - - takeScreenshot: e2e/_artifacts/screenshots/drag-reorder-02-after-drop.png - # Exit edit-order mode (lock again) - tapOn: id: "server-order-toggle" @@ -84,18 +63,14 @@ appId: com.ronenmars.threadbase - tapOn: id: "filter-sort-button" - - assertVisible: - id: "filter-sort-sheet" - - # Reorder persists across sheet close/reopen — both rows still rendered. - # (Asserting absolute order in Maestro 2.x without view-tree access - # would require a screenshot diff; we instead rely on the integration - # test in __tests__/integration to cover the order assertion.) - - assertVisible: - id: "server-toggle-srv_a" + - extendedWaitUntil: + visible: + id: "filter-sort-sheet" + timeout: 5000 + # Toggle still available with 2+ servers after close/reopen. - assertVisible: - id: "server-toggle-srv_b" + id: "server-order-toggle" # ── Single-server branch (default after setup.yaml) ──────────────────────── # When only one server is paired the lock toggle must be hidden. @@ -107,3 +82,5 @@ appId: com.ronenmars.threadbase # Toggle is correctly hidden — confirms the `>= 2` gate works. - assertNotVisible: id: "server-order-toggle" + + - takeScreenshot: e2e/_artifacts/screenshots/drag-reorder-01-single-server.png diff --git a/e2e/session_lifecycle.yaml b/e2e/session_lifecycle.yaml new file mode 100644 index 00000000..27b2d45f --- /dev/null +++ b/e2e/session_lifecycle.yaml @@ -0,0 +1,41 @@ +# Maestro E2E — Session lifecycle hub → browse entry +# Asserts the new-session FAB still opens browse (the handoff surface for +# start-session). Deep session-detail / abandon-empty paths are covered by +# Jest (sessionLifecycle + SessionScreen.holdOnBackground). +# +# Requires: mock server on localhost:7071 +# Usage: maestro test e2e/session_lifecycle.yaml + +appId: com.ronenmars.threadbase +--- +- runFlow: setup.yaml + +- assertVisible: + id: "hub-screen" + +- assertVisible: + id: "fab-new-session" + +- tapOn: + id: "fab-new-session" + +- extendedWaitUntil: + visible: + id: "browse-screen" + timeout: 8000 + +- assertVisible: + id: "browse-start-session" + +- takeScreenshot: e2e/_artifacts/screenshots/session-lifecycle-01-browse + +# Leave browse without starting — modal dismiss must return a usable hub. +- back + +- extendedWaitUntil: + visible: + id: "hub-screen" + timeout: 8000 + +- assertVisible: + id: "fab-new-session" diff --git a/hooks/useBackup.ts b/hooks/useBackup.ts new file mode 100644 index 00000000..8fb54fc9 --- /dev/null +++ b/hooks/useBackup.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { exportBackup, restoreBackup } from '@/services/backup' +import type { BackupArchive, RestorePathMapRule } from '@/types/backup' + +export function useBackupExport(serverId: string | null, enabled: boolean) { + return useQuery({ + queryKey: ['backup-export', serverId], + queryFn: ({ signal }) => exportBackup(serverId!, signal), + enabled: !!serverId && enabled, + staleTime: 0, + retry: 1, + }) +} + +export function useBackupRestore(serverId: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (args: { + archive: BackupArchive + apply?: boolean + pathMap?: RestorePathMapRule[] + }) => restoreBackup(serverId!, args.archive, { apply: args.apply, pathMap: args.pathMap }), + onSuccess: () => { + if (serverId) { + void qc.invalidateQueries({ queryKey: ['projects'] }) + void qc.invalidateQueries({ queryKey: ['sessions'] }) + } + }, + }) +} diff --git a/hooks/useClaudeFlags.ts b/hooks/useClaudeFlags.ts new file mode 100644 index 00000000..c71c3aa8 --- /dev/null +++ b/hooks/useClaudeFlags.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { getClaudeFlags, updateClaudeFlags } from '@/services/api-client' +import type { ClaudeFlagsConfig, ClaudeFlagValues } from '@/types/api' + +/** + * Per-server Claude CLI flags, read from the server. + * + * Deliberately NOT persisted to disk (the key root is absent from + * PERSISTED_QUERY_ROOTS in services/query-client.ts): these values live on the + * streamer and can be changed from the CLI there, so a cached copy would show + * the user a security setting the server no longer has. + * + * `null` data means the server predates the feature — callers hide the UI. + */ +export function useClaudeFlags(serverId: string) { + return useQuery<ClaudeFlagsConfig | null>({ + queryKey: ['claudeFlags', serverId], + queryFn: () => getClaudeFlags(serverId), + enabled: !!serverId, + }) +} + +/** + * Replace the whole flag set. + * + * No optimistic update on purpose: this can turn Claude's permission prompts + * off, and a write that silently rolls back would leave the user believing a + * security setting took effect when it didn't. Callers show a spinner and wait. + */ +export function useUpdateClaudeFlags(serverId: string) { + const qc = useQueryClient() + return useMutation< + ClaudeFlagsConfig, + Error, + { values: ClaudeFlagValues; extraArgs?: string } + >({ + mutationFn: ({ values, extraArgs }) => updateClaudeFlags(serverId, values, extraArgs), + onSuccess: (data) => { + // Seed from the response rather than only invalidating: the server + // normalises the values (dropping unknown ids), so its copy is authoritative. + qc.setQueryData(['claudeFlags', serverId], data) + qc.invalidateQueries({ queryKey: ['claudeFlags', serverId] }) + }, + }) +} diff --git a/hooks/useComposerState.ts b/hooks/useComposerState.ts index 5ff4ed4a..6b91dd31 100644 --- a/hooks/useComposerState.ts +++ b/hooks/useComposerState.ts @@ -89,7 +89,10 @@ export function useComposerState({ serverId, sessionId, onSend }: UseComposerSta const buildPayload = (text: string): string | null => { const trimmed = text.trim() if (!trimmed && attachments.length === 0) return null - const refs = attachments.map((a) => `@${a.path}`).join(' ') + // Escape spaces in paths so Claude Code's @path parser doesn't split them. + // The streamer now sanitizes filenames, but this handles legacy uploads. + const escapePath = (p: string) => p.replace(/ /g, '\\ ') + const refs = attachments.map((a) => `@${escapePath(a.path)}`).join(' ') return refs && trimmed ? `${refs} ${trimmed}` : refs || trimmed } diff --git a/hooks/useConversations.ts b/hooks/useConversations.ts index 6f8ba5b7..dedec224 100644 --- a/hooks/useConversations.ts +++ b/hooks/useConversations.ts @@ -1,13 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query' import { AppState } from 'react-native' import { createApiForServer } from '@/services/api-client' +import { getServerWarmupState } from '@/services/server-warmup' import { getEtag, setEtag, deleteEtag } from '@/services/etag-store' import { QUERY_GC_TIME, SEVEN_DAYS } from '@/services/query-client' import { wsManager } from '@/services/ws-client' import { useServersStore } from '@/stores/servers' import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' -import type { Conversation, ConversationDetail, ConversationFilter, ConversationPage, Message, MessageContent, MultiConversation, TurnDuration, UnavailableReason } from '@/types/api' +import type { Conversation, ConversationDetail, ConversationFilter, ConversationPage, DiffHunk, Message, MessageContent, MultiConversation, TurnDuration, UnavailableReason } from '@/types/api' import type { ConversationPageParam } from '@/hooks/conversationCursor' import { deriveCursor, @@ -27,6 +28,7 @@ interface RawSessionMeta { id: string profile_id?: string project_name?: string + session_name?: string project_path?: string last_updated_at?: string message_count?: number @@ -67,7 +69,8 @@ function adaptPage(raw: RawSessionMeta[] | ConversationPage, offset: number, lim } const conversations: Conversation[] = raw.filter((s): s is RawSessionMeta => s != null).map((s) => ({ id: s.id, - title: s.project_name ?? 'Conversation', + title: s.session_name?.trim() || s.project_name || 'Conversation', + sessionName: s.session_name, projectPath: s.project_path ?? '', branch: s.git_branch, messageCount: s.message_count ?? 0, @@ -95,10 +98,11 @@ export function useConversations(filter?: ConversationFilter, refreshEpoch = 0) const recordSuccess = useServerFetchStatusStore((s) => s.recordSuccess) const recordFailure = useServerFetchStatusStore((s) => s.recordFailure) + const recordWarmingUp = useServerFetchStatusStore((s) => s.recordWarmingUp) return useInfiniteQuery({ queryKey: ['conversations', filter, refreshEpoch, ...displayedServerIds], - queryFn: async ({ pageParam = 0 }): Promise<MultiConversationPage> => { + queryFn: async ({ pageParam = 0, signal }): Promise<MultiConversationPage> => { // Bug 32: use allSettled so one unreachable server doesn't blank the Hub. // Rejected results update the per-server fetch-status store, which the // header dot + ServerStatusModal read to surface partial failure. @@ -118,12 +122,15 @@ export function useConversations(filter?: ConversationFilter, refreshEpoch = 0) } const raw = await api.get<RawSessionMeta[] | ConversationPage>( `/api/conversations?${params.toString()}`, + { signal }, ) const page = adaptPage(raw, pageParam as number, limit) return { serverId, page } }) ) + if (signal.aborted) throw new Error('aborted') + const fulfilled: { serverId: string; page: ConversationPage }[] = [] const failedServers: string[] = [] settled.forEach((result, idx) => { @@ -133,7 +140,9 @@ export function useConversations(filter?: ConversationFilter, refreshEpoch = 0) recordSuccess(serverId) } else { failedServers.push(serverId) - recordFailure(serverId, result.reason) + const warmupState = getServerWarmupState(result.reason) + if (warmupState) recordWarmingUp(serverId, warmupState) + else recordFailure(serverId, result.reason) } }) @@ -189,6 +198,9 @@ interface RawContentBlock { tool_use_id?: string content?: string is_error?: boolean + // structured diff (when streamer emits it) + filename?: string + hunks?: DiffHunk[] } interface RawMessage { @@ -263,6 +275,8 @@ function adaptRawMessage(m: RawMessage, convId: string, fallbackIndex: number): content: block.content ?? '', isError: block.is_error, }) + } else if (block.type === 'diff' && block.filename && Array.isArray(block.hunks)) { + content.push({ type: 'diff', filename: block.filename, hunks: block.hunks }) } } // The server carries assistant prose in the top-level `text` field, never @@ -312,6 +326,26 @@ function adaptRawMessage(m: RawMessage, convId: string, fallbackIndex: number): } } +// Reuse the previous render's Message object for any id whose content is +// unchanged, so a rebuilt list keeps stable references for existing rows. +// FlashList then treats a live reload as an append, not a full data swap, and +// never blank-remeasures. Content compared by JSON equality — Messages are +// plain data (no functions), so this is exact and cheap at conversation sizes. +export function reuseMessageIdentities(prev: Message[], next: Message[]): Message[] { + if (prev.length === 0) return next + const prevById = new Map(prev.map((m) => [m.id, m])) + let changed = false + const out = next.map((m) => { + const old = prevById.get(m.id) + if (old && old !== m && JSON.stringify(old) === JSON.stringify(m)) { + changed = true + return old + } + return m + }) + return changed ? out : next +} + /** Pages are ordered newest-chunk first (infinite query page 0 = tail). Merge oldest → newest. */ function mergeConversationPages(pages: RawConversationDetail[]): ConversationDetail { if (pages.length === 0) { @@ -331,7 +365,7 @@ function mergeConversationPages(pages: RawConversationDetail[]): ConversationDet return { id: convId, - title: first.meta.project_name ?? 'Conversation', + title: first.meta.session_name?.trim() || first.meta.project_name || 'Conversation', projectPath: first.meta.project_path ?? '', branch: first.meta.git_branch, messageCount: first.meta.message_count ?? messages.length, @@ -474,8 +508,19 @@ export function useConversation( queryRef.current = query }) + // Imperative handle to the delta drain below. Consumers (the read-only + // conversation view's focus-poll interval + conversation_updated listener) + // invoke it to run one throttled drain. Points at a no-op whenever the effect + // is inactive (anchored window / disabled consumer), so a stale runDelta can + // never fire after the deps flip. + const triggerDeltaRef = useRef<() => void>(() => {}) + const triggerDelta = useCallback(() => triggerDeltaRef.current(), []) + const triggerEnabled = opts?.enabled !== false useEffect(() => { + // Reset the imperative handle first; only the active tail-view path below + // re-points it at a live runDelta. + triggerDeltaRef.current = () => {} // Delta-on-open lives only on the tail view; anchored windows are // navigation artifacts with their own bidirectional pagination. A consumer // that mounts with enabled: false must not trigger either — imperative @@ -554,6 +599,13 @@ export function useConversation( } } + // Expose the drain to imperative callers (focus-poll interval, + // conversation_updated listener). Same throttle applies — runDelta gates on + // canTrigger internally. + triggerDeltaRef.current = () => { + void runDelta() + } + // Mount. void runDelta() @@ -597,9 +649,27 @@ export function useConversation( } }, [serverId, id, anchorIndex, queryKeyHash, queryClient, triggerEnabled]) + // Every drain rebuilds ConversationDetail from raw pages, so each Message is a + // fresh object even when its content is byte-identical to the one already on + // screen. Feeding FlashList a wholly new-identity array on a live reload makes + // it drop and re-measure every cell (startRenderingFromBottom), which paints a + // blank frame — the reload "blink". Reuse the prior object for any id whose + // content is unchanged so existing rows keep stable references and only genuine + // appends read as new. (See reuseMessageIdentities.test.ts.) + // + // The prev-messages cache is a plain render-time identity cache: it only swaps + // equal objects for equal objects, so it can never change WHETHER this memo + // recomputes (that is fully decided by the query.data dep) — the exact case + // the refs-in-render lint exists to catch does not apply here. + const prevMessagesRef = useRef<Message[]>([]) const data = useMemo(() => { if (!query.data?.pages.length) return undefined - return mergeConversationPages(query.data.pages) + const merged = mergeConversationPages(query.data.pages) + // eslint-disable-next-line react-hooks/refs -- render-time identity cache; see note above + merged.messages = reuseMessageIdentities(prevMessagesRef.current, merged.messages) + // eslint-disable-next-line react-hooks/refs -- render-time identity cache; see note above + prevMessagesRef.current = merged.messages + return merged }, [query.data]) const firstPage = query.data?.pages[0] @@ -625,22 +695,11 @@ export function useConversation( isFetchingNewerPage: query.isFetchingPreviousPage, totalMessages, loadedMessages, + // Imperative delta-drain trigger (throttled) for freshness pollers. + triggerDelta, } } -// Sentinel thrown when the /conversations/count request times out so the outer -// handler can classify the server as "indexing" rather than "unreachable". -class CountTimeoutError extends Error { - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause)) - this.name = 'CountTimeoutError' - } -} - -function isCountTimeoutError(err: unknown): boolean { - return err instanceof CountTimeoutError -} - // Drain one server's pages sequentially — keeps server load proportional to // progress rather than firing N pages × 3 servers in parallel at every focus. async function fetchAllConversationPagesForServer( @@ -658,22 +717,11 @@ async function fetchAllConversationPagesForServer( if (filter?.projectPath) countParams.set('project', filter.projectPath) if (filter?.provider) countParams.set('provider', filter.provider) const countQs = countParams.toString() - let total: number - try { - const res = await api.get<{ total: number }>( - `/api/conversations/count${countQs ? `?${countQs}` : ''}`, - { signal }, - ) - total = res.total - } catch (err) { - // Re-throw aborts as CountTimeoutError so the caller can distinguish - // "server is indexing" from "server is unreachable". - const msg = err instanceof Error ? err.message : String(err) - if (msg.includes('AbortError') || msg.includes('cancelled') || msg.includes('timed out')) { - throw new CountTimeoutError(err) - } - throw err - } + const count = await api.get<{ total: number }>( + `/api/conversations/count${countQs ? `?${countQs}` : ''}`, + { signal }, + ) + const total = count.total onProgress(0, total) @@ -717,7 +765,7 @@ export function useEagerConversations(filter?: ConversationFilter, refreshEpoch const [progress, setProgress] = useState<EagerConversationsProgress>({ loaded: 0, total: 0 }) const recordSuccess = useServerFetchStatusStore((s) => s.recordSuccess) const recordFailure = useServerFetchStatusStore((s) => s.recordFailure) - const recordIndexing = useServerFetchStatusStore((s) => s.recordIndexing) + const recordWarmingUp = useServerFetchStatusStore((s) => s.recordWarmingUp) const queryKey = useMemo( () => ['conversations-eager', filter, refreshEpoch, ...displayedServerIds], @@ -796,13 +844,9 @@ export function useEagerConversations(filter?: ConversationFilter, refreshEpoch // Extract serverId from the rejection — find matching index. const idx = settled.indexOf(result) if (idx !== -1) { - // A count-request timeout means the server responded to other requests - // but the index scan is still warm — show "indexing", not "unreachable". - if (isCountTimeoutError(result.reason)) { - recordIndexing(displayedServerIds[idx]) - } else { - recordFailure(displayedServerIds[idx], result.reason) - } + const warmupState = getServerWarmupState(result.reason) + if (warmupState) recordWarmingUp(displayedServerIds[idx], warmupState) + else recordFailure(displayedServerIds[idx], result.reason) } } } diff --git a/hooks/useDevices.ts b/hooks/useDevices.ts new file mode 100644 index 00000000..4bff2566 --- /dev/null +++ b/hooks/useDevices.ts @@ -0,0 +1,22 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchDevices, revokeDevice } from '@/services/devices' + +export function useDevices(serverId: string | null) { + return useQuery({ + queryKey: ['devices', serverId], + queryFn: ({ signal }) => fetchDevices(serverId!, signal), + enabled: !!serverId, + staleTime: 15_000, + retry: 1, + }) +} + +export function useRevokeDevice(serverId: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (deviceId: string) => revokeDevice(serverId!, deviceId), + onSuccess: () => { + if (serverId) void qc.invalidateQueries({ queryKey: ['devices', serverId] }) + }, + }) +} diff --git a/hooks/useProviderHealth.ts b/hooks/useProviderHealth.ts new file mode 100644 index 00000000..d980a3ab --- /dev/null +++ b/hooks/useProviderHealth.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchProviderHealth } from '@/services/provider-health' + +export function useProviderHealth(serverId: string | null | undefined) { + return useQuery({ + queryKey: ['provider-health', serverId], + queryFn: ({ signal }) => fetchProviderHealth(serverId!, signal), + enabled: !!serverId, + staleTime: 60_000, + retry: 1, + }) +} diff --git a/hooks/usePushHealth.ts b/hooks/usePushHealth.ts new file mode 100644 index 00000000..d6355ef7 --- /dev/null +++ b/hooks/usePushHealth.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchPushHealth } from '@/services/push-health' + +export function usePushHealth(serverId: string | null) { + return useQuery({ + queryKey: ['push-health', serverId], + queryFn: ({ signal }) => fetchPushHealth(serverId!, signal), + enabled: !!serverId, + staleTime: 15_000, + retry: 1, + }) +} diff --git a/hooks/useReducedMotion.ts b/hooks/useReducedMotion.ts new file mode 100644 index 00000000..454afc00 --- /dev/null +++ b/hooks/useReducedMotion.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import { AccessibilityInfo } from 'react-native' + +/** + * Tracks the OS “Reduce Motion” setting. Default false until the native + * query resolves so first paint stays consistent with prior behavior. + */ +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false) + + useEffect(() => { + let mounted = true + void AccessibilityInfo.isReduceMotionEnabled().then((value) => { + if (mounted) setReduced(value) + }) + const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduced) + return () => { + mounted = false + sub.remove() + } + }, []) + + return reduced +} diff --git a/hooks/useServerDiagnostics.ts b/hooks/useServerDiagnostics.ts new file mode 100644 index 00000000..680110f6 --- /dev/null +++ b/hooks/useServerDiagnostics.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchServerDiagnostics } from '@/services/server-diagnostics' + +export function useServerDiagnostics(serverId: string | null) { + return useQuery({ + queryKey: ['server-diagnostics', serverId], + queryFn: ({ signal }) => fetchServerDiagnostics(serverId!, signal), + enabled: !!serverId, + staleTime: 15_000, + retry: 1, + }) +} diff --git a/hooks/useSession.ts b/hooks/useSession.ts index 881c4931..eac9dd71 100644 --- a/hooks/useSession.ts +++ b/hooks/useSession.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createApiForServer } from '@/services/api-client' +import { getServerWarmupState } from '@/services/server-warmup' import { useServersStore } from '@/stores/servers' import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' import type { @@ -117,6 +118,7 @@ export function useEagerSessions(args: UseEagerSessionsArgs = {}): UseEagerSessi const servers = useServersStore((s) => s.servers) const recordSuccess = useServerFetchStatusStore((s) => s.recordSuccess) const recordFailure = useServerFetchStatusStore((s) => s.recordFailure) + const recordWarmingUp = useServerFetchStatusStore((s) => s.recordWarmingUp) const sortBy: SortBy = args.sort?.sortBy ?? 'lastActivity' const order: SortOrder = args.sort?.order ?? 'desc' @@ -195,7 +197,9 @@ export function useEagerSessions(args: UseEagerSessionsArgs = {}): UseEagerSessi return sessions } catch (err) { if (signal?.aborted) throw err - recordFailure(serverId, err) + const warmupState = getServerWarmupState(err) + if (warmupState) recordWarmingUp(serverId, warmupState) + else recordFailure(serverId, err) return [] as MultiSession[] } finally { const slice = serverProgressRef.current.get(serverId) @@ -282,5 +286,8 @@ export function useSessionDetail(serverId: string, sessionId: string) { // Don't persist session detail across app restarts — each session is // ephemeral and stale persisted state causes false status flickers. meta: { persist: false }, + // A vanished session is authoritative — retrying a 404 only delays the + // not-found recovery UI and keeps stale favorites pinned longer. + retry: false, }) } diff --git a/hooks/useSessionActions.ts b/hooks/useSessionActions.ts index f755ebcf..5433ef46 100644 --- a/hooks/useSessionActions.ts +++ b/hooks/useSessionActions.ts @@ -1,7 +1,18 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { createApiForServer, NetworkError, stopSession } from '@/services/api-client' import { useSessionsStore } from '@/stores/sessions' -import type { MultiSession, QueuedPrompt } from '@/types/api' +import type { MultiSession, QueuedPrompt, Session } from '@/types/api' +import type { ResumeConversationResponse } from '@/types/projectChat' +import { normalizeResumeResponse } from '@/utils/normalizeResumeResponse' + +/** Normalised result of a successful resume, ready for cache-seed + navigation. */ +export interface ResumeResult { + sessionId: string + projectId?: string + projectPath?: string | null + conversationId: string + sessionSnapshot: Session | null +} // networkMode stays default 'online': a send fired while offline auto-pauses and // is replayed by resumePausedMutations() on reconnect. retry bridges the @@ -88,6 +99,36 @@ export function useSessionActions(serverId: string, sessionId: string) { }, }) + // Resumes the conversation `sessionId` into a live PTY session. Non-idempotent + // (spawns a PTY) — retry:false so a client timeout never double-spawns. A soft + // 409 (the conversation may still be open elsewhere) surfaces as a + // ConversationBusyError to onError, carrying the structured detection payload; + // callers confirm and retry with `{ force: true }`. Normalises both the modern + // ResumeConversationResponse and the legacy `{ id }` shape. + const resume = useMutation({ + mutationFn: async ({ force }: { force?: boolean } = {}): Promise<ResumeResult> => { + const resp = await api.post<ResumeConversationResponse | { id: string }>( + '/api/sessions/resume', + { sessionId, ...(force ? { force: true } : {}) }, + { retry: false }, + ) + if ('sessionId' in resp) { + return { + sessionId: resp.sessionId, + projectId: resp.projectId, + projectPath: resp.projectPath, + conversationId: resp.conversationId, + sessionSnapshot: normalizeResumeResponse(resp), + } + } + return { + sessionId: resp.id, + conversationId: sessionId, + sessionSnapshot: null, + } + }, + }) + // Hard-kills the PTY via /stop. Status is driven idle by the WS session_update // the server broadcasts after the stream closes, so we only refresh the lists. const stopSessionMutation = useMutation({ @@ -99,5 +140,5 @@ export function useSessionActions(serverId: string, sessionId: string) { }, }) - return { sendInput, sendKeys, cancelSession, addToQueue, removeFromQueue, respondToPlan, respondToQuestion, adoptSession, stopSession: stopSessionMutation } + return { sendInput, sendKeys, cancelSession, addToQueue, removeFromQueue, respondToPlan, respondToQuestion, adoptSession, resume, stopSession: stopSessionMutation } } diff --git a/hooks/useTBPair.ts b/hooks/useTBPair.ts index 469a4cfe..5427fb1f 100644 --- a/hooks/useTBPair.ts +++ b/hooks/useTBPair.ts @@ -1,5 +1,14 @@ import { useCallback, useRef, useState } from 'react' import { AuthError, NetworkError } from '@/services/api-client' +import { + classifyPairCredential, + exchangeToken, + parsePairUri, + PairExchangeError, + PairUriError, +} from '@/services/pair-exchange' +import { defaultPairDeviceName } from '@/services/pair-device-name' +import type { DeviceCapability } from '@/types/devices' export type PairLogKind = 'i' | 'd' | 'ok' | 'err' @@ -13,12 +22,19 @@ export type PairPhase = 'idle' | 'dialing' | 'resolving' | 'handshake' | 'ok' | export interface PairResult { url: string apiKey: string + /** Optional display name (user-entered or machine name from pair exchange). */ + label?: string + deviceId?: string + deviceToken?: string + capabilities?: DeviceCapability[] } interface PairOptions { url: string token: string onSuccess?: (result: PairResult) => void + /** When true, request a read-only device credential from the streamer. */ + readOnly?: boolean } // Schedule per HANDOFF: 200/700/1100/1700ms; auto-advance 700ms after `paired`. @@ -30,7 +46,88 @@ const SCHEDULE = { done: 2400, } -// Mocks the handshake in dev; calls a real /api/profiles auth check in prod. +function defaultDeviceName(): string { + return defaultPairDeviceName() +} + +async function resolveCredentials( + url: string, + token: string, + readOnly = false, +): Promise<PairResult> { + const trimmedUrl = url.replace(/\/$/, '') + const trimmedToken = token.trim() + const kind = classifyPairCredential(trimmedToken) + const deviceName = defaultDeviceName() + + if (kind === 'pair-uri') { + const parsed = parsePairUri(trimmedToken) + const exchanged = await exchangeToken({ + url: parsed.url, + token: parsed.token, + deviceName, + readOnly, + }) + return { + url: exchanged.url, + apiKey: exchanged.apiKey, + label: exchanged.machineName ?? undefined, + deviceId: exchanged.deviceId ?? undefined, + deviceToken: exchanged.deviceToken ?? undefined, + capabilities: exchanged.capabilities ?? undefined, + } + } + + if (kind === 'pair-token') { + const exchanged = await exchangeToken({ + url: trimmedUrl, + token: trimmedToken, + deviceName, + readOnly, + }) + return { + url: exchanged.url, + apiKey: exchanged.apiKey, + label: exchanged.machineName ?? undefined, + deviceId: exchanged.deviceId ?? undefined, + deviceToken: exchanged.deviceToken ?? undefined, + capabilities: exchanged.capabilities ?? undefined, + } + } + + // Long-lived API key (`tb_…`): Bearer-check /api/profiles. + const res = await fetch(`${trimmedUrl}/api/profiles`, { + headers: { Authorization: `Bearer ${trimmedToken}` }, + }) + if (res.status === 401) throw new AuthError() + if (!res.ok) throw new NetworkError(`HTTP ${res.status}`) + await res.json() + return { url: trimmedUrl, apiKey: trimmedToken } +} + +function messageForPairFailure(err: unknown): string { + if (err instanceof PairUriError) { + if (err.code === 'expired') return 'pair link expired · run tb pair again' + if (err.code === 'bad-server-url') return 'invalid server URL in pair link' + return 'invalid pair link · paste the threadbase:// URL from tb pair' + } + if (err instanceof PairExchangeError) { + if (err.kind === 'token') return 'token rejected · run tb pair again' + if (err.kind === 'rate-limited') return 'too many attempts · try again shortly' + if (err.kind === 'network') return 'connection refused · is the server running?' + if (err.kind === 'decrypt') return 'could not unseal api key' + return 'exchange failed' + } + if (err instanceof AuthError) { + return 'token rejected · check THREADBASE_API_KEY' + } + if (err instanceof NetworkError || err instanceof TypeError) { + return 'connection refused · is the server running?' + } + return 'handshake failed' +} + +// Mocks the handshake in dev; resolves pair tokens / URIs / API keys in prod. export function useTBPair() { const [phase, setPhase] = useState<PairPhase>('idle') const [log, setLog] = useState<PairLogLine[]>([]) @@ -58,7 +155,7 @@ export function useTBPair() { }, [append]) const pair = useCallback( - ({ url, token, onSuccess }: PairOptions) => { + ({ url, token, onSuccess, readOnly }: PairOptions) => { if (phase !== 'idle' && phase !== 'err') return reset() setPhase('dialing') @@ -71,11 +168,11 @@ export function useTBPair() { const trimmedUrl = url.replace(/\/$/, '') const finishMockSequence = () => { schedule(SCHEDULE.dial, () => { - append({ k: 'i', t: `dial ${trimmedUrl}` }) + append({ k: 'i', t: `dial ${trimmedUrl || 'pair-uri'}` }) setPhase('resolving') }) schedule(SCHEDULE.resolve, () => { - append({ k: 'd', t: 'mdns → 192.168.1.42:7331' }) + append({ k: 'd', t: 'mdns → 192.168.1.42:8766' }) setPhase('handshake') }) schedule(SCHEDULE.handshake, () => { @@ -95,20 +192,22 @@ export function useTBPair() { return } - // Prod: real auth check against the server before advancing. + const dialTarget = + classifyPairCredential(token) === 'pair-uri' ? 'pair-uri' : trimmedUrl + schedule(SCHEDULE.dial, () => { - append({ k: 'i', t: `dial ${trimmedUrl}` }) + append({ k: 'i', t: `dial ${dialTarget}` }) setPhase('resolving') }) ;(async () => { try { - const res = await fetch(`${trimmedUrl}/api/profiles`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (res.status === 401) throw new AuthError() - if (!res.ok) throw new NetworkError(`HTTP ${res.status}`) - await res.json() + const kind = classifyPairCredential(token) + if (kind === 'pair-uri' || kind === 'pair-token') { + append({ k: 'd', t: 'exchanging pair token…' }) + } + + const result = await resolveCredentials(url, token, readOnly === true) schedule(SCHEDULE.resolve - SCHEDULE.dial, () => { append({ k: 'd', t: 'mdns → handshake' }) @@ -122,16 +221,10 @@ export function useTBPair() { setPhase('ok') }) schedule(SCHEDULE.done - SCHEDULE.dial, () => { - onSuccess?.({ url: trimmedUrl, apiKey: token }) + onSuccess?.(result) }) } catch (err) { - if (err instanceof AuthError) { - fail('token rejected · check THREADBASE_API_KEY') - } else if (err instanceof NetworkError || err instanceof TypeError) { - fail('connection refused · is the server running?') - } else { - fail('handshake failed') - } + fail(messageForPairFailure(err)) } })() }, diff --git a/hooks/useTerminalStream.ts b/hooks/useTerminalStream.ts index fb7f6e07..940ce9a6 100644 --- a/hooks/useTerminalStream.ts +++ b/hooks/useTerminalStream.ts @@ -5,6 +5,8 @@ import { useSettingsStore } from '@/stores/settings' import { createApiForServer, NotFoundError } from '@/services/api-client' import { QUERY_GC_TIME } from '@/services/query-client' import { VirtualTerminal } from '@/services/virtual-terminal' +import type { ProviderName } from '@/constants/providers' +import type { ParseConfidence } from '@/lib/renderConfidence' export type TerminalLine = string @@ -21,9 +23,15 @@ const TERMINAL_REPLAY_TIMEOUT_MS = 2000 // the app is in the foreground). Force a reconnect so streaming resumes. const WS_SILENCE_TIMEOUT_MS = 45_000 -export function useTerminalStream(serverId: string, sessionId: string, skipLiveStream = false) { +export function useTerminalStream( + serverId: string, + sessionId: string, + skipLiveStream = false, + provider?: ProviderName | string | null, +) { const maxLines = useSettingsStore((s) => s.terminalMaxLines) const [lines, setLines] = useState<TerminalLine[]>([]) + const [parseConfidence, setParseConfidence] = useState<ParseConfidence>('high') const [isStreaming, setIsStreaming] = useState(false) // Ground-truth set of texts the streamer wrote to the PTY, normalized (trim). // Lets the renderer positively identify user-owned lines instead of guessing. @@ -52,6 +60,16 @@ export function useTerminalStream(serverId: string, sessionId: string, skipLiveS // HTTP fallback query — disabled by default, enabled only when WS replay times out const [httpFallbackEnabled, setHttpFallbackEnabled] = useState(false) + function publishLines() { + const vt = vtRef.current! + const confidence = vt.getParseConfidence() + setParseConfidence(confidence) + // Low parse confidence → raw lines so we never present chrome-filtered + // output as if normalization were authoritative. + const visible = confidence === 'low' ? vt.getRawLines() : vt.getLines() + setLines(visible.slice(-maxLines)) + } + const historyQuery = useQuery({ queryKey: ['terminal-output', serverId, sessionId], queryFn: async () => { @@ -74,10 +92,10 @@ export function useTerminalStream(serverId: string, sessionId: string, skipLiveS if (historyFedRef.current) return historyFedRef.current = true vtRef.current!.reset() + vtRef.current!.setProvider(provider) setLines([]) vtRef.current!.feed(raw) - const visible = vtRef.current!.getLines() - setLines(visible.slice(-maxLines)) + publishLines() } // Feed HTTP fallback history whenever it loads (only if replay wasn't received) @@ -108,11 +126,21 @@ export function useTerminalStream(serverId: string, sessionId: string, skipLiveS historyFedRef.current = false queueMicrotask(() => { setLines([]) + setParseConfidence('high') setHttpFallbackEnabled(false) setUserMessageTexts(new Set()) }) }, [serverId, sessionId]) + useEffect(() => { + vtRef.current?.setProvider(provider) + if (historyFedRef.current || lines.length > 0) { + publishLines() + } + // Re-apply chrome filter when provider identity arrives after first paint. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]) + useEffect(() => { if (skipLiveStream) return @@ -148,6 +176,13 @@ export function useTerminalStream(serverId: string, sessionId: string, skipLiveS unsubReplay?.() unsubReplay = client.on('terminal_replay', (msg) => { if (msg.type !== 'terminal_replay' || msg.sessionId !== sessionId) return + // A card-parked session can replay only blank ring-buffer rows. Treating + // that as a successful load latches replayReceivedRef and disarms the + // HTTP fallback, stranding the terminal blank even though /output holds + // the full transcript. Only accept a replay that carries content; leave + // the fallback timer armed otherwise so /output fills the screen. + const hasContent = msg.lines.some((line) => line.trim().length > 0) + if (!hasContent) return replayReceivedRef.current = true if (fallbackTimer) { clearTimeout(fallbackTimer) @@ -175,7 +210,7 @@ export function useTerminalStream(serverId: string, sessionId: string, skipLiveS setIsStreaming(true) vtRef.current!.feed(msg.data) - setLines(vtRef.current!.getLines().slice(-maxLines)) + publishLines() clearTimeout(idleTimer) idleTimer = setTimeout(() => setIsStreaming(false), 1500) @@ -242,7 +277,15 @@ export function useTerminalStream(serverId: string, sessionId: string, skipLiveS const clear = useCallback(() => { vtRef.current!.reset() setLines([]) + setParseConfidence('high') }, []) - return { lines, isStreaming, userMessageTexts, isLoadingHistory: historyQuery.isPending && httpFallbackEnabled, clear } + return { + lines, + isStreaming, + userMessageTexts, + parseConfidence, + isLoadingHistory: historyQuery.isPending && httpFallbackEnabled, + clear, + } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3e3f25e8..4ef95f04 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -21,16 +21,16 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - EASClient (57.0.0): + - EASClient (57.0.1): - ExpoModulesCore - - EXApplication (57.0.0): + - EXApplication (57.0.2): - ExpoModulesCore - - EXConstants (57.0.2): + - EXConstants (57.0.7): - ExpoModulesCore - - EXJSONUtils (57.0.0) - - EXManifests (57.0.0): + - EXJSONUtils (57.0.1) + - EXManifests (57.0.1): - ExpoModulesCore - - Expo (57.0.1): + - Expo (57.0.8): - ExpoModulesCore - ExpoModulesJSI - hermes-engine @@ -56,15 +56,15 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - expo-dev-client (57.0.3): + - expo-dev-client (57.0.9): - EXManifests - expo-dev-launcher - expo-dev-menu - expo-dev-menu-interface - EXUpdatesInterface - - expo-dev-launcher (57.0.3): + - expo-dev-launcher (57.0.9): - EXManifests - - expo-dev-launcher/Main (= 57.0.3) + - expo-dev-launcher/Main (= 57.0.9) - expo-dev-menu - expo-dev-menu-interface - ExpoModulesCore @@ -93,7 +93,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - expo-dev-launcher/Main (57.0.3): + - expo-dev-launcher/Main (57.0.9): - EXManifests - expo-dev-launcher/Unsafe - expo-dev-menu @@ -124,7 +124,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - expo-dev-launcher/Unsafe (57.0.3): + - expo-dev-launcher/Unsafe (57.0.9): - EXManifests - expo-dev-menu - expo-dev-menu-interface @@ -154,8 +154,8 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - expo-dev-menu (57.0.3): - - expo-dev-menu/Main (= 57.0.3) + - expo-dev-menu (57.0.9): + - expo-dev-menu/Main (= 57.0.9) - hermes-engine - RCTRequired - RCTTypeSafety @@ -178,7 +178,7 @@ PODS: - ReactNativeDependencies - Yoga - expo-dev-menu-interface (57.0.0) - - expo-dev-menu/Main (57.0.3): + - expo-dev-menu/Main (57.0.9): - EXManifests - expo-dev-menu-interface - ExpoModulesCore @@ -204,47 +204,47 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ExpoAsset (57.0.2): + - ExpoAsset (57.0.7): - ExpoModulesCore - - ExpoBlur (57.0.0): + - ExpoBlur (57.0.2): - ExpoModulesCore - - ExpoCamera (57.0.1): + - ExpoCamera (57.0.3): - ExpoModulesCore - - ExpoCameraBarcodeScanning (57.0.1): + - ExpoCameraBarcodeScanning (57.0.3): - ExpoCamera - - ExpoClipboard (57.0.0): + - ExpoClipboard (57.0.1): - ExpoModulesCore - - ExpoDevice (57.0.0): + - ExpoDevice (57.0.1): - ExpoModulesCore - - ExpoDocumentPicker (57.0.0): + - ExpoDocumentPicker (57.0.1): - ExpoModulesCore - ExpoDomWebView (57.0.1): - ExpoModulesCore - - ExpoFileSystem (57.0.0): + - ExpoFileSystem (57.0.1): - ExpoModulesCore - - ExpoFont (57.0.0): + - ExpoFont (57.0.1): - ExpoModulesCore - ExpoGlassEffect (57.0.1): - ExpoModulesCore - - ExpoHaptics (57.0.0): + - ExpoHaptics (57.0.1): - ExpoModulesCore - - ExpoImageManipulator (57.0.1): + - ExpoImageManipulator (57.0.6): - ExpoModulesCore - - ExpoImagePicker (57.0.1): + - ExpoImagePicker (57.0.6): - ExpoModulesCore - - ExpoKeepAwake (57.0.0): + - ExpoKeepAwake (57.0.1): - ExpoModulesCore - ExpoLinearGradient (57.0.1): - ExpoModulesCore - - ExpoLinking (57.0.1): + - ExpoLinking (57.0.4): - ExpoModulesCore - - ExpoLocalAuthentication (57.0.0): + - ExpoLocalAuthentication (57.0.2): - ExpoModulesCore - - ExpoLocalization (57.0.0): + - ExpoLocalization (57.0.1): - ExpoModulesCore - ExpoLogBox (57.0.1): - React-Core - - ExpoMailComposer (57.0.0): + - ExpoMailComposer (57.0.1): - ExpoModulesCore - ExpoModulesCore (57.0.7): - ExpoModulesJSI @@ -281,27 +281,27 @@ PODS: - ExpoModulesJSI - ExpoModulesWorklets - RNWorklets - - ExpoNotifications (57.0.2): + - ExpoNotifications (57.0.7): - ExpoModulesCore - - ExpoRouter (57.0.6): + - ExpoRouter (57.0.8): - ExpoModulesCore - RNScreens - - ExpoSecureStore (57.0.0): + - ExpoSecureStore (57.0.1): - ExpoModulesCore - ExpoSpeechRecognition (56.0.1): - ExpoModulesCore - - ExpoSplashScreen (57.0.1): + - ExpoSplashScreen (57.0.5): - ExpoModulesCore - ExpoSymbols (57.0.1): - ExpoModulesCore - - ExpoSystemUI (57.0.0): + - ExpoSystemUI (57.0.1): - ExpoModulesCore - - ExpoUI (57.0.6): + - ExpoUI (57.0.7): - ExpoModulesCore - ExpoModulesWorklets - React-RCTFabric - EXStructuredHeaders (57.0.0) - - EXUpdates (57.0.4): + - EXUpdates (57.0.10): - EASClient - EXManifests - ExpoModulesCore @@ -329,7 +329,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - EXUpdatesInterface (57.0.0): + - EXUpdatesInterface (57.0.1): - ExpoModulesCore - FBLazyVector (0.86.0) - hermes-engine (250829098.0.14): @@ -2480,7 +2480,7 @@ PODS: - ReactNativeDependencies - RNWorklets - Yoga - - RNScreens (4.25.2): + - RNScreens (4.26.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2502,9 +2502,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNScreens/common (= 4.25.2) + - RNScreens/common (= 4.26.2) - Yoga - - RNScreens/common (4.25.2): + - RNScreens/common (4.26.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3084,54 +3084,54 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c - EASClient: 7d3aa729ae2cd433b5bc314a6d3543fad381fa1c - EXApplication: 7283a281903a90230466ab545ab87a8c48cf4e12 - EXConstants: ac0976f6962735f39a25730074869614cb1a5d85 - EXJSONUtils: 55eca2b6f8fc320cb36933cf45c4f2200a441589 - EXManifests: 66a0e4af8cdb58c0983d462d886136b6d56eee38 - Expo: 15f5a16e9369278c1781ad3aa17d364245703635 - expo-dev-client: c75c8b9f0cbea96a0959438102d7ce2b0ad49465 - expo-dev-launcher: 24fe56ae4a0754d7bffcce0ad5c8b16bf3c02d61 - expo-dev-menu: d592b33bf68fd8a672d43e857d290800cb6fe9e6 + EASClient: 81f44ce9f07ec3d01606507213d21510a5f97388 + EXApplication: bbd517d50878ca1d121fb3843392beb210181e28 + EXConstants: fa5647482320b7bde855057f8b86580456d4b9f0 + EXJSONUtils: 7a1683cfa2cbe6d11fca97097d51a496755b0287 + EXManifests: f9888c0bf5aaa6ddb7e3d4b224d6d54ac4ca84a4 + Expo: 4a3b7c95f7f7251c884c75699d60d653555ff9b9 + expo-dev-client: c638e315c5aff7b6c4147c45ffdf43aabfa1ae43 + expo-dev-launcher: c999a63ea4daa61bcc188568f2120c8983033226 + expo-dev-menu: adff9ec508e3c027a9d7c1b9e154b062cd501a8b expo-dev-menu-interface: 7a59e803916fbfd1b96c75ff91ea8dddac7e722f - ExpoAsset: f9f1fc226653b9f7ab995873d9b4f929d2a916c7 - ExpoBlur: 14d0cc31e6a1910b9712dfce1bd8a43d698cd5ae - ExpoCamera: d30fb3af6f5e97d1567c3b2d84413707a78d9005 - ExpoCameraBarcodeScanning: 0cd97b138b31f7534201816611dd164543429b37 - ExpoClipboard: 67d46a8be4a50ec610c7b2f4966c0475aec6d969 - ExpoDevice: 090ee05e20e40ca981d39263e3e95c46ed850782 - ExpoDocumentPicker: 1160f4221c6ed4fed906aad0761549ea163b7a01 + ExpoAsset: 98143758ff37e14c9ff2a4e7673097db00473011 + ExpoBlur: 69e67b4fbfed6f7325377af2aefa8aa4f00cd2af + ExpoCamera: a5e9d9ce2fc06689eb2dd676f1f8d5f22f8aa292 + ExpoCameraBarcodeScanning: b869ac87ebe30bb99b26c55b0e0471301383c90b + ExpoClipboard: 95055b11758f339b61d4d8064dd799df1cf03b7f + ExpoDevice: 93a72d7c2bd656a4f0c0d580523e390e9ee7fba5 + ExpoDocumentPicker: 6fb67a6f39ba9beb53a4b4a3f2949e0b5e9bc3bf ExpoDomWebView: beaec034e51bd028428b98bb1f2633ab79c6d095 - ExpoFileSystem: ffcd56066188802f263254b6b2e60f74b6a0bdae - ExpoFont: 35fd7ad72f697d3a9656545909972d8b9f60d5da + ExpoFileSystem: e441dae0ae671fc451640517550973d9e024fdf0 + ExpoFont: 59e1faf66ba9bcd232ae1e2ce58d202a48b6f65e ExpoGlassEffect: 4b62afb489e6b84357701737ce002137c9d5fdbc - ExpoHaptics: c6d063c3bfda1c1d6d7fdf237ffb612c4fc09954 - ExpoImageManipulator: db2be3b3fe592c57d5993886f3bdf57d4a4d5041 - ExpoImagePicker: 0afefebdfe3aacd2281535eada9d59345afa3118 - ExpoKeepAwake: 8a96e2075b75641001f55393d7e6600a5b808f09 + ExpoHaptics: 4503d2da51ff7109712ccdfe0e4939f66818183e + ExpoImageManipulator: d3c962dabd86b331980f8ec6794c6073b3cb541a + ExpoImagePicker: 8edc8d1215d9c8c95c44101e42d577f8beb898c9 + ExpoKeepAwake: c26f14275017370cc8a4b7b43a0e23361f2053a5 ExpoLinearGradient: 903b3f5fe566c666ff78b2599add51d094483613 - ExpoLinking: db6d7a9e9348cca9ada7c39127ab30963deddba3 - ExpoLocalAuthentication: 4f2c3ddae58a42e93564fcf2be4518c463fbdd71 - ExpoLocalization: 543cb852ec9d4a6c732344f94ebd96d18897eaab + ExpoLinking: 5a796f9284535c0349537a844dd40680229b7fcb + ExpoLocalAuthentication: 96989637e567a10cdeabe61e5c6026d30c0dc53f + ExpoLocalization: 659243b03b3a3e9589793cee2f728a899e181329 ExpoLogBox: 8a3cfa2897e088083cd49fc1ff7fdb2de4385547 - ExpoMailComposer: 93f4d65c12dd45331f69e0906773aa0e7b74344c - ExpoModulesCore: 6abb896a08e54893c79f170b43cda72cc64f9348 + ExpoMailComposer: 101933dc6bdb4d7a46495d1dd87420365325adb9 + ExpoModulesCore: 3082834d5d10d2ea67c1fbfcd7f191518ed3aec4 ExpoModulesJSI: b20fefa4cdd9097e7e6b715466d1900b8c5d4711 ExpoModulesWorklets: b21b8f233b5fd71ca74327217601fa4ead9ddaaf ExpoModulesWorkletsAdapter: 6b3dffb299973f4f2b1a96738aa2cb46987b18e1 - ExpoNotifications: 9e7946eed73d94bbe0b7188906be41b0cc236c40 - ExpoRouter: 4f2ffaa0520703446edf643ee35b1ac1c11c65f9 - ExpoSecureStore: be2276d312c1f213605c0864772510239605ec37 + ExpoNotifications: a2f19a01dad3ee8631ec283561882ff9f302f3e4 + ExpoRouter: c086c97b2229152334ab68200ade40b64f75515b + ExpoSecureStore: 94793113049a2d7478dfe1a13aa15081e9f61755 ExpoSpeechRecognition: 119acf84448579e53f841d040aad76982f945e2d - ExpoSplashScreen: 70840fce2f0a181eb5060cde8a727e999ac4c941 + ExpoSplashScreen: 247464c0fe484f766892db0d66c3fe54f92a624e ExpoSymbols: 7c7c7bd3c52f0b6dcbba6e8af4088b0dc1ea7d29 - ExpoSystemUI: 4f343f83e81951d737b199ac9c0d9db0d577f942 - ExpoUI: d8760712c07860ee5cdf93c78279c857668a40bc + ExpoSystemUI: 6f2ce8968292aaa91f4aa553c23bdd8cee5cdc85 + ExpoUI: 19f2ca6e82d91240530ee9762dedccc31fc6f429 EXStructuredHeaders: 1423c3602a30eb078e03b63b251ef912267dd4c0 - EXUpdates: 6bfc710fb7d7219ec3da729e72ae003e523f0c17 - EXUpdatesInterface: 492cdb30f3889cc35d9e737a4b5fc3aec39d1131 + EXUpdates: cbdbe424e30c11983c2841930050114c1b8bea0c + EXUpdatesInterface: 92d2aa5194b6fb93ca8ab749db75665cb28b2e60 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: 82b14fe6a4b57b835581d32346f9e8d04a067b68 + hermes-engine: 7a039a55e4e8ca01b3efce17360bea8c248fb90d RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 RCTSwiftUI: 5aaf0b07e747ba749dc6acc94d8bd41eea4b570f @@ -3213,8 +3213,8 @@ SPEC CHECKSUMS: RNCMaskedView: eb2b2e538afa907f05a5848a1a1ac26092e6fec9 RNGestureHandler: 92172e79df6e88e0e93b6341f9dcd8b5a02746ba RNReanimated: a9bf68fb5f19ce1c363b7d1413dcf8ee323185b8 - RNScreens: f94000b3cb1eb14cbb8d08dd6074fdd6ccf0e6b0 - RNSentry: 1379dbcba661977664b1b9c5ae802130ef8e5e96 + RNScreens: c1a06fd73d0581a2a28a160577be393f579662b9 + RNSentry: 7bb2dcf95d8e545ffcb96155c9e5ebfbb95c8916 RNSVG: 6cae8d4082913be2eb43eaec2a456222c42195ed RNWorklets: 53439575fed8525fb8d1cabcc05a3d4d91e6ffe4 SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d diff --git a/jest.setup.js b/jest.setup.js index 901d6aa7..7ba0b5b7 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -24,24 +24,33 @@ jest.mock('@react-native-community/netinfo', () => ({ })) // ─── expo-router ───────────────────────────────────────────────────────────── -jest.mock('expo-router', () => ({ - useRouter: jest.fn(() => ({ - push: jest.fn(), - replace: jest.fn(), - back: jest.fn(), - navigate: jest.fn(), - canGoBack: jest.fn(() => true), - })), - useLocalSearchParams: jest.fn(() => ({})), - useGlobalSearchParams: jest.fn(() => ({})), - useNavigation: jest.fn(() => ({ setOptions: jest.fn() })), - useSegments: jest.fn(() => []), - router: { push: jest.fn(), replace: jest.fn(), back: jest.fn(), canGoBack: jest.fn(() => true) }, - Redirect: () => null, - Link: ({ children }) => children, - Stack: { Screen: () => null }, - Tabs: { Screen: () => null }, -})) +jest.mock('expo-router', () => { + const React = require('react') + return { + useRouter: jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + back: jest.fn(), + navigate: jest.fn(), + setParams: jest.fn(), + canGoBack: jest.fn(() => true), + })), + useLocalSearchParams: jest.fn(() => ({})), + useGlobalSearchParams: jest.fn(() => ({})), + useNavigation: jest.fn(() => ({ setOptions: jest.fn(), addListener: jest.fn(() => jest.fn()) })), + useSegments: jest.fn(() => []), + // Screens are always focused under test — run the effect on mount and + // return its cleanup so blur/unmount tears the interval down. + useFocusEffect: (cb) => { + React.useEffect(() => cb(), [cb]) + }, + router: { push: jest.fn(), replace: jest.fn(), back: jest.fn(), canGoBack: jest.fn(() => true) }, + Redirect: () => null, + Link: ({ children }) => children, + Stack: { Screen: () => null }, + Tabs: { Screen: () => null }, + } +}) // ─── expo-haptics ──────────────────────────────────────────────────────────── jest.mock('expo-haptics', () => ({ diff --git a/lib/eagerCacheSync.ts b/lib/eagerCacheSync.ts new file mode 100644 index 00000000..70e95ce2 --- /dev/null +++ b/lib/eagerCacheSync.ts @@ -0,0 +1,46 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { MultiSession, Session } from '@/types/api' + +/** + * Reconcile a `session_update` WS frame into the eager home-screen session list + * (`['sessions-eager', …]`). If the row is already cached, patch it in place + * (fast path, no refetch). If it is NOT cached in any eager query yet — e.g. an + * external session that just became alive — invalidate so the list refetches + * and the row appears without a manual pull-to-refresh. Invalidation (rather + * than a blind insert) is used because the eager query key carries a status + * filter, and a refetch respects it where an inserted row might not. + */ +export function applySessionUpdateToEagerCache( + queryClient: QueryClient, + serverId: string, + session: Session, +): void { + const entries = queryClient.getQueriesData<MultiSession[]>({ queryKey: ['sessions-eager'] }) + const present = entries.some( + ([, data]) => + Array.isArray(data) && data.some((s) => s.serverId === serverId && s.id === session.id), + ) + + if (present) { + queryClient.setQueriesData<MultiSession[]>({ queryKey: ['sessions-eager'] }, (old) => + Array.isArray(old) + ? old.map((s) => + s.serverId === serverId && s.id === session.id ? { ...s, ...session } : s, + ) + : old, + ) + return + } + + void queryClient.invalidateQueries({ queryKey: ['sessions-eager'] }) +} + +/** + * A `conversation_updated` ping means an external conversation's JSONL grew (or + * its owner changed) with no PTY the streamer owns. Refresh the eager + * conversations list so the row's message count / last activity update without + * a manual pull-to-refresh. + */ +export function refreshEagerConversations(queryClient: QueryClient): void { + void queryClient.invalidateQueries({ queryKey: ['conversations-eager'] }) +} diff --git a/lib/externalSession.ts b/lib/externalSession.ts new file mode 100644 index 00000000..c62e5961 --- /dev/null +++ b/lib/externalSession.ts @@ -0,0 +1,31 @@ +import type { Session } from '@/types/api' + +/** + * A session whose underlying process the streamer does NOT own — a CLI it + * discovered but only observes. These are read-only: the app must never send + * input, cancel, or overtake them (overtaking SIGTERMs the user's real + * terminal). Route them to the read-only conversation view instead of the PTY + * screen. Additive field; older servers omit `ownership`, so this is false for + * them and their existing (adopt/overtake) behaviour is preserved. + */ +export function isExternalSession(s: Pick<Session, 'ownership'>): boolean { + return s.ownership === 'external' +} + +/** + * Whether an external session's process appears to be alive, for the distinct + * "external — alive" indicator. Keyed on the additive liveness fields, with a + * pid fallback for older servers that send `pid` for a discovered process but + * none of the new fields (managed PTY + historical shapes never carry `pid`). + */ +export function isExternalAlive( + s: Pick<Session, 'processLiveness' | 'activity' | 'pid'>, +): boolean { + // New-server signals are authoritative when present. + if (s.processLiveness === 'alive') return true + if (s.processLiveness === 'gone') return false + if (s.activity?.state === 'active_writing') return true + // Older-server fallback: a bare pid with none of the new liveness fields. + if (s.processLiveness == null && s.activity == null && s.pid != null) return true + return false +} diff --git a/lib/projectDisambiguation.ts b/lib/projectDisambiguation.ts new file mode 100644 index 00000000..06fa25dd --- /dev/null +++ b/lib/projectDisambiguation.ts @@ -0,0 +1,32 @@ +/** + * Paths that appear under more than one server — used to force server chips + * so duplicate projects stay disambiguated in multi-server hubs. + */ +export function collidingProjectPaths( + items: readonly { projectPath?: string | null; serverId: string }[], +): Set<string> { + const serversByPath = new Map<string, Set<string>>() + for (const item of items) { + const path = item.projectPath?.trim() + if (!path) continue + let servers = serversByPath.get(path) + if (!servers) { + servers = new Set() + serversByPath.set(path, servers) + } + servers.add(item.serverId) + } + const colliding = new Set<string>() + for (const [path, servers] of serversByPath) { + if (servers.size > 1) colliding.add(path) + } + return colliding +} + +export function shouldForceServerChip( + projectPath: string | null | undefined, + colliding: Set<string>, +): boolean { + const path = projectPath?.trim() + return Boolean(path && colliding.has(path)) +} diff --git a/lib/renderConfidence.ts b/lib/renderConfidence.ts new file mode 100644 index 00000000..10969f7b --- /dev/null +++ b/lib/renderConfidence.ts @@ -0,0 +1,67 @@ +/** + * Client-only heuristics for whether normalized chat can be treated as + * authoritative. Never invent streamer APIs — prefer raw terminal when unsure. + */ + +export type ParseConfidence = 'high' | 'low' + +export type RenderSurfaceMode = 'terminal' | 'chat' + +export type PreferRawReason = + | 'user_preference' + | 'no_conversation' + | 'low_parse_confidence' + | 'chat_empty_pty_active' + +export interface PreferRawTerminalInput { + sessionView: 'chat' | 'terminal' + hasConversationId: boolean + conversationMessageCount: number + ptyVisibleLineCount: number + parseConfidence: ParseConfidence +} + +export interface PreferRawTerminalResult { + mode: RenderSurfaceMode + reason: PreferRawReason + /** When true, UI must not present chat bubbles as ground truth. */ + chatAuthoritative: boolean +} + +const PTY_ACTIVE_WITHOUT_CHAT_THRESHOLD = 24 + +export function preferRawTerminal(input: PreferRawTerminalInput): PreferRawTerminalResult { + if (!input.hasConversationId) { + return { mode: 'terminal', reason: 'no_conversation', chatAuthoritative: false } + } + if (input.parseConfidence === 'low') { + return { mode: 'terminal', reason: 'low_parse_confidence', chatAuthoritative: false } + } + if ( + input.sessionView === 'chat' && + input.conversationMessageCount === 0 && + input.ptyVisibleLineCount >= PTY_ACTIVE_WITHOUT_CHAT_THRESHOLD + ) { + return { mode: 'terminal', reason: 'chat_empty_pty_active', chatAuthoritative: false } + } + if (input.sessionView === 'terminal') { + return { mode: 'terminal', reason: 'user_preference', chatAuthoritative: false } + } + return { mode: 'chat', reason: 'user_preference', chatAuthoritative: true } +} + +/** Map unsupported/truncated escape pressure into a coarse confidence band. */ +export function parseConfidenceFromCounters(input: { + unsupportedSequenceCount: number + truncatedEscapeCount: number + bytesFed: number +}): ParseConfidence { + const { unsupportedSequenceCount, truncatedEscapeCount, bytesFed } = input + if (bytesFed < 64) return 'high' + if (truncatedEscapeCount > 3) return 'low' + if (unsupportedSequenceCount >= 12) return 'low' + if (bytesFed > 0 && unsupportedSequenceCount / Math.max(1, Math.floor(bytesFed / 256)) > 4) { + return 'low' + } + return 'high' +} diff --git a/lib/reviewFromConversation.ts b/lib/reviewFromConversation.ts new file mode 100644 index 00000000..82877dc2 --- /dev/null +++ b/lib/reviewFromConversation.ts @@ -0,0 +1,215 @@ +import type { DiffHunk, DiffLine, Message, MessageContent } from '@/types/api' + +export type ReviewFileKind = 'edited' | 'written' | 'diff' | 'unknown' + +export interface ReviewFile { + path: string + kind: ReviewFileKind + hunks: DiffHunk[] + added: number + removed: number + incompleteReasons: string[] + oversized: boolean + sourceToolNames: string[] +} + +export interface ReviewSummary { + files: ReviewFile[] + totalAdded: number + totalRemoved: number + incomplete: boolean + hasOversized: boolean +} + +const EDIT_TOOLS = new Set(['Edit', 'edit', 'MultiEdit', 'multiedit']) +const WRITE_TOOLS = new Set(['Write', 'write', 'NotebookEdit', 'NotebookEditCell']) +const LARGE_FILE_LINE_CAP = 400 +const LARGE_REVIEW_FILE_CAP = 80 + +function asString(value: string | number | boolean | null | undefined | object): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readInputString(input: Record<string, string | number | boolean | null | object | undefined>, key: string): string | null { + return asString(input[key]) +} + +type MultiEditRow = { + old_string?: string + new_string?: string +} + + +function countHunkChanges(hunks: DiffHunk[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const hunk of hunks) { + for (const line of hunk.lines) { + if (line.type === 'addition') added++ + else if (line.type === 'deletion') removed++ + } + } + return { added, removed } +} + +function linesFromText(text: string, type: DiffLine['type']): DiffLine[] { + if (text.length === 0) return [] + return text.split('\n').map((content) => ({ type, content })) +} + +/** Build a single hunk from old/new string pairs (Edit tool). */ +export function hunkFromEdit(oldString: string, newString: string): DiffHunk { + const deletions = linesFromText(oldString, 'deletion') + const additions = linesFromText(newString, 'addition') + return { + oldStart: 1, + oldLines: Math.max(1, deletions.length), + newStart: 1, + newLines: Math.max(1, additions.length), + lines: [...deletions, ...additions], + } +} + +/** Treat a full Write payload as all-additions. */ +export function hunkFromWrite(content: string): DiffHunk { + const additions = linesFromText(content, 'addition') + return { + oldStart: 0, + oldLines: 0, + newStart: 1, + newLines: Math.max(1, additions.length), + lines: additions, + } +} + +function pushFile( + map: Map<string, ReviewFile>, + path: string, + kind: ReviewFileKind, + hunks: DiffHunk[], + toolName: string, + incompleteReasons: string[] = [], +): void { + const existing = map.get(path) + const mergedHunks = existing ? [...existing.hunks, ...hunks] : hunks + const { added, removed } = countHunkChanges(mergedHunks) + const totalLines = mergedHunks.reduce((n, h) => n + h.lines.length, 0) + const oversized = totalLines > LARGE_FILE_LINE_CAP + const reasons = [ + ...(existing?.incompleteReasons ?? []), + ...incompleteReasons, + ] + if (oversized && !reasons.includes('oversized')) reasons.push('oversized') + + map.set(path, { + path, + kind: existing?.kind === 'written' || kind === 'written' ? 'written' : kind, + hunks: oversized + ? mergedHunks.map((h) => ({ + ...h, + lines: h.lines.slice(0, LARGE_FILE_LINE_CAP), + })) + : mergedHunks, + added, + removed, + incompleteReasons: Array.from(new Set(reasons)), + oversized, + sourceToolNames: Array.from(new Set([...(existing?.sourceToolNames ?? []), toolName])), + }) +} + +function ingestToolUse(map: Map<string, ReviewFile>, block: Extract<MessageContent, { type: 'tool_use' }>): void { + const name = block.name + const input = block.input as Record<string, string | number | boolean | null | object | undefined> + + if (EDIT_TOOLS.has(name)) { + const path = readInputString(input, 'file_path') ?? readInputString(input, 'path') + if (!path) return + + if (name === 'MultiEdit' || name === 'multiedit') { + const editsRaw = input.edits + const edits = Array.isArray(editsRaw) ? (editsRaw as MultiEditRow[]) : [] + if (edits.length === 0) { + pushFile(map, path, 'edited', [], name, ['missing_edit_payload']) + return + } + for (const edit of edits) { + const oldString = asString(edit?.old_string) ?? '' + const newString = asString(edit?.new_string) ?? '' + if (!oldString && !newString) continue + pushFile(map, path, 'edited', [hunkFromEdit(oldString, newString)], name) + } + return + } + + const oldString = readInputString(input, 'old_string') ?? '' + const newString = readInputString(input, 'new_string') ?? '' + if (!oldString && !newString) { + pushFile(map, path, 'edited', [], name, ['missing_edit_payload']) + return + } + pushFile(map, path, 'edited', [hunkFromEdit(oldString, newString)], name) + return + } + + if (WRITE_TOOLS.has(name)) { + const path = readInputString(input, 'file_path') ?? readInputString(input, 'path') + const content = readInputString(input, 'content') ?? readInputString(input, 'new_string') ?? '' + if (!path) return + if (!content) { + pushFile(map, path, 'written', [], name, ['missing_write_payload']) + return + } + pushFile(map, path, 'written', [hunkFromWrite(content)], name) + } +} + +/** + * Build a read-only review surface from conversation messages. + * Uses Edit/Write tool payloads and structured diff blocks only — + * not a full git status (no streamer git API). + */ +export function buildReviewFromMessages(messages: Message[]): ReviewSummary { + const map = new Map<string, ReviewFile>() + + for (const message of messages) { + for (const block of message.content) { + if (block.type === 'diff') { + pushFile(map, block.filename, 'diff', block.hunks, 'diff') + continue + } + if (block.type === 'tool_use') { + ingestToolUse(map, block) + } + } + } + + let files = Array.from(map.values()).sort((a, b) => a.path.localeCompare(b.path)) + const truncatedList = files.length > LARGE_REVIEW_FILE_CAP + if (truncatedList) { + files = files.slice(0, LARGE_REVIEW_FILE_CAP) + } + + const totalAdded = files.reduce((n, f) => n + f.added, 0) + const totalRemoved = files.reduce((n, f) => n + f.removed, 0) + const incomplete = + truncatedList || + files.some((f) => f.incompleteReasons.length > 0) || + files.length === 0 + const hasOversized = files.some((f) => f.oversized) + + return { files, totalAdded, totalRemoved, incomplete, hasOversized } +} + +export function formatReviewNote(summary: ReviewSummary, selectedPath?: string): string { + const lines = [ + 'Mobile review note (conversation-derived, may be incomplete vs git status):', + `Files: ${summary.files.length} · +${summary.totalAdded} / −${summary.totalRemoved}`, + ] + if (selectedPath) lines.push(`Focus: ${selectedPath}`) + for (const file of summary.files.slice(0, 20)) { + lines.push(`- ${file.path} (+${file.added}/−${file.removed}) [${file.kind}]`) + } + if (summary.files.length > 20) lines.push(`…and ${summary.files.length - 20} more`) + return lines.join('\n') +} diff --git a/lib/serverUrl.ts b/lib/serverUrl.ts new file mode 100644 index 00000000..c4ff92e3 --- /dev/null +++ b/lib/serverUrl.ts @@ -0,0 +1,18 @@ +/** + * Client-side HTTP(S) server URL checks used before dialing a pair exchange + * or saving a manual server. Mirrors the scheme rules in pair-exchange but + * returns a boolean so the UI can show an inline error without throwing. + */ +export function isValidHttpServerUrl(raw: string): boolean { + const trimmed = raw.trim() + if (!trimmed) return false + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return false + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false + if (!parsed.hostname) return false + return true +} diff --git a/lib/sessionLifecycle.ts b/lib/sessionLifecycle.ts new file mode 100644 index 00000000..49e8513d --- /dev/null +++ b/lib/sessionLifecycle.ts @@ -0,0 +1,67 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { MultiSession } from '@/types/api' +import { useQuickAccessStore, buildFavoriteId } from '@/stores/quickAccess' + +/** + * Drop a vanished session from every eager hub list so a stale favorite / + * row cannot keep resurrecting a "Session not found" screen after back-nav. + */ +export function removeSessionFromEagerCache( + queryClient: QueryClient, + serverId: string, + sessionId: string, +): void { + queryClient.setQueriesData<MultiSession[]>({ queryKey: ['sessions-eager'] }, (old) => + Array.isArray(old) ? old.filter((s) => !(s.serverId === serverId && s.id === sessionId)) : old, + ) + queryClient.removeQueries({ queryKey: ['session', serverId, sessionId] }) +} + +/** + * Unpin any favorite that pointed at a session the server no longer has. + * Matches both canonical `${serverId}::session::${id}` favorites and legacy + * ids that still carry `sessionId` / split-on-`::` shapes. + */ +export function evictStaleSessionFavorite(serverId: string, sessionId: string): void { + const canonical = buildFavoriteId(serverId, 'session', sessionId) + const { favorites, unpinItem } = useQuickAccessStore.getState() + for (const fav of favorites) { + if (fav.type !== 'session') continue + if (fav.serverId !== serverId) continue + const matchesCanonical = fav.id === canonical + const matchesLegacyField = fav.sessionId === sessionId + const matchesSplitId = fav.id.split('::').at(-1) === sessionId + if (matchesCanonical || matchesLegacyField || matchesSplitId) { + unpinItem(fav.id) + } + } +} + +export function evictStaleConversationFavorite(serverId: string, conversationId: string): void { + const canonical = buildFavoriteId(serverId, 'conversation', conversationId) + const { favorites, unpinItem } = useQuickAccessStore.getState() + for (const fav of favorites) { + if (fav.type !== 'conversation') continue + if (fav.serverId !== serverId) continue + if (fav.id === canonical || fav.conversationId === conversationId) { + unpinItem(fav.id) + } + } +} + +/** + * After foreground / WS reconnect: force a fresh session detail pull and, when + * known, the bound conversation so the screen does not keep rendering a + * pre-disconnect cache snapshot. + */ +export function rehydrateSessionAfterReconnect( + queryClient: QueryClient, + serverId: string, + sessionId: string, + conversationId?: string | null, +): void { + void queryClient.invalidateQueries({ queryKey: ['session', serverId, sessionId] }) + if (conversationId) { + void queryClient.invalidateQueries({ queryKey: ['conversation', serverId, conversationId] }) + } +} diff --git a/lib/sessionPresentation.ts b/lib/sessionPresentation.ts new file mode 100644 index 00000000..7fa53d74 --- /dev/null +++ b/lib/sessionPresentation.ts @@ -0,0 +1,283 @@ +import type { UnavailableReason } from '@/types/api' + +/** + * Canonical session/conversation presentation kinds for hub rows, badges, and + * the session status bar. Derived only from existing wire fields — no private API. + */ +export type SessionKind = + | 'managed_live' + | 'external_live' + | 'historical' + | 'resumed' + | 'on_hold' + | 'completed' + | 'unavailable' + | 'stale' + | 'idle' + +export type SessionColorToken = 'running' | 'waiting' | 'completed' | 'idle' | 'failed' + +export type SessionConfidence = 'process' | 'jsonl' | 'status' | 'unknown' + +export interface SessionCapabilities { + canSendInput: boolean + canCancel: boolean + canOvertake: boolean + canResume: boolean + isObserveOnly: boolean +} + +export type SessionStatusLabelKey = + | 'status.running' + | 'status.waiting' + | 'status.idle' + | 'status.externalLive' + | 'status.historical' + | 'status.resumed' + | 'status.onHold' + | 'status.completed' + | 'status.failed' + | 'status.unavailablePath' + | 'status.unavailableWorktree' + | 'status.stale' + +export interface SessionPresentation { + kind: SessionKind + labelKey: SessionStatusLabelKey + live: boolean + externalLive: boolean + colorToken: SessionColorToken + confidence: SessionConfidence + activityAt: string | null + capabilities: SessionCapabilities +} + +/** Fields the presentation helper needs from a session (or session-like) row. */ +export type SessionPresentationInput = { + /** Runtime may still emit legacy / on_hold values not in SessionStatus. */ + status: string + ownership?: 'managed' | 'external' | 'historical' + processLiveness?: 'alive' | 'gone' | 'unknown' + activity?: { state: 'active_writing' | 'quiet'; lastEventAt: string; source: 'jsonl' } + pid?: number + ptyAttached?: boolean + resumedFromConversationId?: string | null + completedAt?: string + failureReason?: string +} + +export interface ConversationPresentationInput { + resumable?: boolean | null + unavailableReason?: UnavailableReason | null +} + +function isExternalSession(s: Pick<SessionPresentationInput, 'ownership'>): boolean { + return s.ownership === 'external' +} + +function isExternalAlive( + s: Pick<SessionPresentationInput, 'processLiveness' | 'activity' | 'pid'>, +): boolean { + if (s.processLiveness === 'alive') return true + if (s.processLiveness === 'gone') return false + if (s.activity?.state === 'active_writing') return true + if (s.processLiveness == null && s.activity == null && s.pid != null) return true + return false +} + +const OBSERVE_ONLY: SessionCapabilities = { + canSendInput: false, + canCancel: false, + canOvertake: false, + canResume: false, + isObserveOnly: true, +} + +const MANAGED_LIVE_CAPS: SessionCapabilities = { + canSendInput: true, + canCancel: true, + canOvertake: false, + canResume: false, + isObserveOnly: false, +} + +const IDLE_MANAGED_CAPS: SessionCapabilities = { + canSendInput: false, + canCancel: false, + canOvertake: false, + canResume: false, + isObserveOnly: false, +} + +function confidenceFor(session: SessionPresentationInput): SessionConfidence { + if (session.processLiveness === 'alive' || session.processLiveness === 'gone') return 'process' + if (session.activity?.source === 'jsonl') return 'jsonl' + if (session.status) return 'status' + return 'unknown' +} + +function activityAtFor(session: SessionPresentationInput): string | null { + return session.activity?.lastEventAt ?? session.completedAt ?? null +} + +/** + * Derive a single presentation model for badges, live pills, and action gates. + */ +export function deriveSessionPresentation( + session: SessionPresentationInput, +): SessionPresentation { + const external = isExternalSession(session) + const externalAlive = external && isExternalAlive(session) + const status = session.status + const confidence = confidenceFor(session) + const activityAt = activityAtFor(session) + const resumed = Boolean(session.resumedFromConversationId) + + if (status === 'on_hold') { + return { + kind: 'on_hold', + labelKey: 'status.onHold', + live: false, + externalLive: false, + colorToken: 'waiting', + confidence, + activityAt, + capabilities: { ...IDLE_MANAGED_CAPS, canSendInput: false, canCancel: true }, + } + } + + if (status === 'completed' || status === 'failed') { + if (status === 'failed' || session.failureReason) { + return { + kind: 'completed', + labelKey: 'status.failed', + live: false, + externalLive: false, + colorToken: 'failed', + confidence, + activityAt, + capabilities: IDLE_MANAGED_CAPS, + } + } + return { + kind: 'completed', + labelKey: 'status.completed', + live: false, + externalLive: false, + colorToken: 'completed', + confidence, + activityAt, + capabilities: IDLE_MANAGED_CAPS, + } + } + + if (externalAlive) { + return { + kind: 'external_live', + labelKey: 'status.externalLive', + live: true, + externalLive: true, + colorToken: 'completed', + confidence: session.processLiveness ? 'process' : confidence, + activityAt, + capabilities: OBSERVE_ONLY, + } + } + + if (external && session.processLiveness === 'gone') { + return { + kind: 'stale', + labelKey: 'status.stale', + live: false, + externalLive: false, + colorToken: 'idle', + confidence: 'process', + activityAt, + capabilities: OBSERVE_ONLY, + } + } + + if (session.ownership === 'historical' || (external && !externalAlive)) { + return { + kind: 'historical', + labelKey: 'status.historical', + live: false, + externalLive: false, + colorToken: 'idle', + confidence, + activityAt, + capabilities: { + ...OBSERVE_ONLY, + canResume: true, + }, + } + } + + if (status === 'running' || status === 'waiting_input') { + if (resumed) { + return { + kind: 'resumed', + labelKey: 'status.resumed', + live: true, + externalLive: false, + colorToken: status === 'waiting_input' ? 'waiting' : 'running', + confidence, + activityAt, + capabilities: MANAGED_LIVE_CAPS, + } + } + return { + kind: 'managed_live', + labelKey: status === 'waiting_input' ? 'status.waiting' : 'status.running', + live: true, + externalLive: false, + colorToken: status === 'waiting_input' ? 'waiting' : 'running', + confidence, + activityAt, + capabilities: MANAGED_LIVE_CAPS, + } + } + + return { + kind: 'idle', + labelKey: 'status.idle', + live: false, + externalLive: false, + colorToken: 'idle', + confidence, + activityAt, + capabilities: IDLE_MANAGED_CAPS, + } +} + +export function deriveConversationPresentation( + conversation: ConversationPresentationInput, +): SessionPresentation | null { + if (conversation.resumable === false && conversation.unavailableReason) { + return { + kind: 'unavailable', + labelKey: + conversation.unavailableReason === 'worktree_removed' + ? 'status.unavailableWorktree' + : 'status.unavailablePath', + live: false, + externalLive: false, + colorToken: 'failed', + confidence: 'status', + activityAt: null, + capabilities: { + canSendInput: false, + canCancel: false, + canOvertake: false, + canResume: false, + isObserveOnly: true, + }, + } + } + return null +} + +/** Shared “is this row live?” including external-alive. */ +export function isPresentationLive(session: SessionPresentationInput): boolean { + return deriveSessionPresentation(session).live +} diff --git a/lib/sessionUsage.ts b/lib/sessionUsage.ts new file mode 100644 index 00000000..4e6f0daa --- /dev/null +++ b/lib/sessionUsage.ts @@ -0,0 +1,18 @@ +// Tracks whether the user has used a live session (sent input / keys / attach). +// Used by the session screen to discard unused sessions on back navigation +// (Bug 16 — abandoned empty sessions cluttering the hub). + +const usedSessionIds = new Set<string>() + +export function markSessionUsed(sessionId: string): void { + if (!sessionId) return + usedSessionIds.add(sessionId) +} + +export function wasSessionUsed(sessionId: string): boolean { + return usedSessionIds.has(sessionId) +} + +export function clearSessionUsed(sessionId: string): void { + usedSessionIds.delete(sessionId) +} diff --git a/lib/terminalChrome.ts b/lib/terminalChrome.ts new file mode 100644 index 00000000..2ad2a0f6 --- /dev/null +++ b/lib/terminalChrome.ts @@ -0,0 +1,95 @@ +import { + CLAUDE_CODE_PROVIDER, + CODEX_CLI_PROVIDER, + type ProviderName, +} from '@/constants/providers' + +/** + * Provider-specific filters for PTY chrome that should not appear as + * transcript content. Keep adapters additive and fall through to keep + * the line when uncertain — never invent streamer APIs for this. + */ +export type TerminalChromeFilter = (line: string) => boolean + +/** True when the line is Claude Code TUI chrome (not transcript content). */ +export function isClaudeTerminalChrome(line: string): boolean { + if (line.length === 0) return true + const trimmed = line.trim() + + const stripped = line.replace(/[\s=\-─━═│┃┌┐└┘├┤┬┴┼╭╮╯╰╱╲\u2500-\u257F\u2580-\u259F]/g, '') + if (stripped.length === 0) return true + const strippedTrimmed = stripped.trim() + if (/^Claude\s*Code\s*v\d/.test(strippedTrimmed)) return true + if (/^Welcome\s*back\b/.test(strippedTrimmed)) return true + + if (/[▛▜▙▟███]{3,}/.test(trimmed)) return true + if (/Welcome to Claude Code/.test(trimmed)) return true + if (/^Welcome back\b/.test(trimmed)) return true + if (/^Claude Code\s+v\d/.test(trimmed)) return true + + if (/^[·✢*✳✶✻✽⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓\s]+$/.test(trimmed)) return true + if (/^[✱✳✶✻✽*·✢]\s+Saut[ée]+d\s+for\s/.test(trimmed)) return true + if (/^[·✢*✳✶✻✽]\s+\w+ing…\s*$/.test(trimmed)) return true + if (/^\w+ing…\s*$/.test(trimmed)) return true + if (/^[·✢*✳✶✻✽]?\s*\w+ing…\s*\(\d+s\b[^)]*\)\s*$/.test(trimmed)) return true + if (/^[✱✳✶✻✽*·✢]\s+\w+\s+for\s+(\d+m\s*)?\d+s\s*$/.test(trimmed)) return true + + if (/^[❯›>]$/.test(trimmed)) return true + if (/^[❯›>]\s+\d+q$/.test(trimmed)) return true + if (/^[❯›>]\s+Try "/.test(trimmed)) return true + if (/\(●oo●\)/.test(trimmed) || /\(◐oo◐\)/.test(trimmed)) return true + if (/^(Opus|Sonnet|Haiku|Fable)\s+\d+(\.\d+)?[\s(|│[]/.test(trimmed)) return true + if (/^Claude\s+\d+\.\d+\s+(Opus|Sonnet|Haiku)/.test(trimmed)) return true + if (/^\|/.test(trimmed)) return true + if (/^[►▶❯]{1,2}\s*(accept edits|auto|plan)\b/i.test(trimmed)) return true + if (/Update available!/.test(trimmed)) return true + if (/^Run:\s+\S/.test(trimmed)) return true + if (/^\$[\d.]+\s+[\d.]+[kmb]?\s+tokens?$/i.test(trimmed)) return true + if (/^[◑◐●]\s*(low|medium|high)\b/i.test(trimmed)) return true + if (/\(shift\+tab to cycle\)/.test(trimmed)) return true + if (/\(ctrl\+o to expand\)/.test(trimmed)) return true + if (/^\.\.\.\s+\+\d+\s+lines\s/.test(trimmed)) return true + if (/rate limit/i.test(trimmed) && /\d+\s*(req|request|min)/i.test(trimmed)) return true + + if (/^Backgrounded agent\b/i.test(trimmed)) return true + if (/\b(came to rest|is running|backgrounded)\b/i.test(trimmed) && /^(Explore|Plan|Task|Agent)\b/.test(trimmed)) return true + if (/^Invalid tool parameters\b/i.test(trimmed)) return true + if (/^Tips? for getting started/i.test(trimmed)) return true + if (/]777;notify/.test(trimmed) || /^tmux;\]/.test(trimmed)) return true + if (/^[╭╮╯╰│─┌┐└┘┤├]+$/.test(trimmed)) return true + + return false +} + +/** Codex TUI chrome is lighter; drop empty / box-only separators for now. */ +export function isCodexTerminalChrome(line: string): boolean { + if (line.length === 0) return true + const trimmed = line.trim() + const stripped = line.replace(/[\s=\-─━═│┃┌┐└┘├┤┬┴┼╭╮╯╰╱╲\u2500-\u257F\u2580-\u259F]/g, '') + if (stripped.length === 0) return true + if (/^codex\b/i.test(trimmed) && /v?\d/.test(trimmed) && trimmed.length < 40) return true + return false +} + +/** Keep every non-empty line — used for raw-terminal fallback. */ +export function isPassthroughChrome(line: string): boolean { + return line.length === 0 +} + +export function getTerminalChromeFilter( + provider?: ProviderName | string | null, + opts?: { raw?: boolean }, +): TerminalChromeFilter { + if (opts?.raw) return isPassthroughChrome + if (provider === CODEX_CLI_PROVIDER) return isCodexTerminalChrome + if (provider === CLAUDE_CODE_PROVIDER || provider == null || provider === '') { + return isClaudeTerminalChrome + } + // Unknown provider: prefer passthrough over wrong Claude filters. + return isPassthroughChrome +} + +/** Keep line when the chrome filter says it is NOT chrome. */ +export function keepTranscriptLine(line: string, filter: TerminalChromeFilter): boolean { + return !filter(line) +} diff --git a/locales/ar/browse.json b/locales/ar/browse.json index 257fc324..d4d34eb0 100644 --- a/locales/ar/browse.json +++ b/locales/ar/browse.json @@ -2,6 +2,10 @@ "nav": { "back": "‹ رجوع", "recentDirs": "المجلدات الأخيرة ({{count}})", + "displayAll": "عرض الكل", + "allRecentDirs": "المجلدات الأخيرة", + "searchRecentDirs": "البحث في المواقع", + "noMatchingDirs": "لا توجد مواقع مطابقة", "newFolder": "مجلد جديد", "create": "إنشاء", "startSession": "ابدأ سيشن هنا" @@ -22,5 +26,15 @@ "retryHint": "اسحب للأسفل لإعادة المحاولة", "startFailed": "فشل بدء السيشن", "startTimeout": "لم يستجب الخادم في الوقت المحدد. قد يكون السيشن لا يزال قيد البدء — تحقق من قائمة السيشنات قبل المحاولة مرة أخرى." + }, + "provider": { + "unavailable": "CLI المزوّد غير مثبت على الخادم. اختر مزوّدًا آخر أو ثبّته على المضيف.", + "noStructuredQuestions": "لا قوائم أسئلة منظمة لهذا المزوّد — ستجيب في الطرفية الخام.", + "observeOnly": "التحكم المباشر غير متاح لهذا المزوّد؛ الجلسات للمراقبة فقط.", + "warning": { + "provider_not_found": "لم يُعثر على CLI المزوّد في PATH الخادم.", + "version_undetectable": "تعذّر قراءة إصدار المزوّد — التوافق غير موثّق.", + "version_unverified": "إصدار المزوّد خارج النطاق الذي وثّقه الستريمر." + } } } diff --git a/locales/ar/common.json b/locales/ar/common.json index 2fa4fee0..32a008d1 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3,41 +3,21 @@ "cancel": "إلغاء", "confirm": "تأكيد", "save": "حفظ", - "done": "تم", "back": "رجوع", "close": "إغلاق", "retry": "إعادة المحاولة", - "remove": "إزالة", - "apply": "تطبيق", - "skip": "تخطي", - "create": "إنشاء", - "export": "تصدير", - "copy": "نسخ", - "paste": "لصق", - "openSettings": "فتح الإعدادات" + "apply": "تطبيق" }, "error": { - "generic": "حدث خطأ ما", - "loadFailed": "تعذّر التحميل", - "connectionFailed": "فشل الاتصال" - }, - "state": { - "loading": "جارٍ التحميل…", - "empty": "لا يوجد شيء هنا بعد", - "noResults": "لا توجد نتائج" + "loadFailed": "تعذّر التحميل" }, "search": { - "placeholder": "بحث…", - "clear": "مسح البحث" + "placeholder": "بحث…" }, "nav": { "top": "↑ الأعلى", "bottom": "↓ الأسفل", - "backArrow": "‹ رجوع" - }, - "time": { - "now": "الآن", - "yesterday": "أمس" + "copyAll": "نسخ الكل" }, "biometricLock": { "title": "Threadbase مقفل", @@ -57,5 +37,9 @@ "enable": "تفعيل", "notNow": "ليس الآن" } + }, + "renderError": { + "title": "تعذر عرض هذا العنصر", + "message": "يتم عرض بديل خام بدلًا من تعطّل الشاشة." } } diff --git a/locales/ar/conversation.json b/locales/ar/conversation.json index d26ed156..5263aeba 100644 --- a/locales/ar/conversation.json +++ b/locales/ar/conversation.json @@ -5,18 +5,12 @@ }, "list": { "loadingMore": "جارٍ تحميل المزيد…", - "empty": "لا توجد رسائل في هذه المحادثة.", - "msgs_zero": "لا رسائل", - "msgs_one": "رسالة واحدة", - "msgs_two": "رسالتان", - "msgs_few": "{{count}} رسائل", - "msgs_many": "{{count}} رسالة", - "msgs_other": "{{count}} رسالة", - "tokens": "{{count}}k توكن" + "empty": "لا توجد رسائل في هذه المحادثة." }, "error": { "loadFailed": "تعذّر تحميل المحادثة", "notFound": "لم تعد هذه المحادثة متاحة على الخادم.", + "back": "العودة إلى المركز", "resumeTimeout": "لم يستجب الخادم في الوقت المحدد. قد يكون السيشن لا يزال قيد البدء — تحقق من قائمة السيشنات قبل المحاولة مرة أخرى." }, "unavailable": { @@ -24,6 +18,27 @@ "worktreeRemoved": "جرى تشغيل هذه المحادثة في شجرة عمل (worktree) خاصة بـ git لم تعد موجودة. يمكنك قراءة السجل، لكن لا يمكن استئنافها.", "cannotResume": "لا يمكن الاستئناف" }, + "resume": { + "resuming": "جارٍ الاستئناف…", + "collisionTitle": "استئناف هذه المحادثة؟", + "collisionMessage": "قد تكون هذه المحادثة ما زالت مفتوحة في طرفية على جهازك — {{reasons}}. قد يؤدي استئنافها هنا إلى التداخل مع تلك الجلسة.", + "confirm": "استئناف على أي حال", + "failed": "تعذّر استئناف هذه المحادثة. يرجى المحاولة مرة أخرى.", + "reason": { + "jsonl_mtime": "جرى تحديث سجلّها قبل لحظات", + "process_argv": "قد تكون هناك عملية طرفية مطابقة ما زالت قيد التشغيل", + "process_cwd": "قد تكون هناك عملية ما زالت تستخدم مجلد مشروعها", + "unknown": "قد يكون برنامج آخر ما زال يستخدمها" + }, + "takeOver": "الاستحواذ", + "takeOverFailed": "تعذّر الاستحواذ على تلك الجلسة. يرجى المحاولة مرة أخرى." + }, + "live": { + "indicator": "مباشر", + "paused": "متوقّف", + "pause": "إيقاف التحديثات المباشرة", + "resume": "استئناف التحديثات المباشرة" + }, "action": { "export": "تصدير", "copyPatch": "نسخ الرقعة", @@ -45,12 +60,36 @@ "matchCount": "{{current}} من {{total}}", "previousMatch": "التطابق السابق", "nextMatch": "التطابق التالي", - "clearSearch": "مسح البحث" + "clearSearch": "مسح البحث", + "placeholder": "البحث في المحادثة…", + "open": "البحث في المحادثة" }, "slowLoading": { "title1": "نفكّ خيوط محادثة طويلة…", "title2": "الرسائل تتأخر بأناقة…", "title3": "الرسائل عالقة في زحمة السير…", "message": "لم نتوقع أن تكون بهذا الثقل. نحمّلها بأسرع ما يمكن." + }, + "review": { + "title": "مراجعة التغييرات", + "open": "فتح المراجعة", + "summary": "{{files}} ملفات · +{{added}} / −{{removed}}", + "incompleteWarning": "مبني من أدوات Edit/Write في المحادثة — قد يكون غير مكتمل مقارنة بـ git status.", + "oversizedWarning": "تم اختصار بعض الفروقات للأداء. انسخ الحزمة للمراجعة على سطح المكتب.", + "filterAll": "الكل", + "kindEdited": "معدّل", + "kindWritten": "مكتوب", + "kindDiff": "Diff", + "kindUnknown": "أخرى", + "searchPlaceholder": "تصفية الملفات…", + "empty": "لا توجد تغييرات ملفات في هذه المحادثة بعد.", + "selectFile": "اختر ملفًا لعرض الفرق.", + "copyHandoff": "نسخ لسطح المكتب", + "sendNote": "إرسال ملاحظة للوكيل", + "sendNoteTitle": "إرسال ملاحظة المراجعة؟", + "sendNoteBody": "يرسل قائمة ملفات مختصرة إلى إدخال الجلسة الحية.", + "cancel": "إلغاء", + "send": "إرسال", + "fileMeta": "{{kind}} · +{{added}} / −{{removed}}" } } diff --git a/locales/ar/feedback.json b/locales/ar/feedback.json index 2bb45753..62897775 100644 --- a/locales/ar/feedback.json +++ b/locales/ar/feedback.json @@ -63,16 +63,12 @@ "success": { "title": "تم اعتماد الملاحظات", "message": "شكراً — وصل تقريرك إلى الطابور دون أي تعارضات دمج.", + "viaSentry": "أُرسِل عبر خدمة Sentry للإبلاغ عن الأعطال", + "viaEmail": "أُرسِل عبر البريد الإلكتروني", "done": "تم" }, "error": { - "title": "فشل الإرسال", - "message": "لم نتمكّن من إرسال تقريرك. ملاحظاتك ما زالت هنا، فيمكنك المحاولة مجدداً.", - "tryAgain": "حاول مجدداً" - }, - "offline": { - "title": "يبدو أن فرع الشبكة غير متصل", - "message": "أعد الاتصال وحاول مجدداً، أو انسخ التقرير لوقت لاحق." + "message": "لم نتمكّن من إرسال تقريرك. ملاحظاتك ما زالت هنا، فيمكنك المحاولة مجدداً." }, "screenshotError": { "title": "لقطة الشاشة كبيرة جداً", diff --git a/locales/ar/onboarding.json b/locales/ar/onboarding.json index ee8f5e47..f6d98ac4 100644 --- a/locales/ar/onboarding.json +++ b/locales/ar/onboarding.json @@ -1,69 +1,47 @@ { "welcome": { - "title": "مرحبًا بك في Threadbase", - "subtitle": "اتصل بسيشنات Claude Code الخاصة بك", "eyebrow": "// AMBIENT CODING", "headline": "اسحب خيطًا.", "headlineAccent": "وشاهده يُنسَج.", "body": "جهاز تحكم عن بُعد لـ Claude Code، على الجهاز الذي تحمله فعلًا.", "cta": "هيا نبدأ" }, - "addServer": { - "title": "إضافة خادم", - "subtitle": "أدخل عنوان خادم Threadbase الخاص بك للبدء" - }, - "browse": { - "title": "تصفّح", - "empty": "لا توجد ملفات", - "emptySubtitle": "هذا المجلد فارغ" - }, - "conversation": { - "title": "المحادثة", - "empty": "لا توجد رسائل", - "emptySubtitle": "لا يوجد سجل محادثات بعد" - }, "shell": { "back": "رجوع", - "skip": "تخطي" - }, - "serverName": { - "eyebrow": "> 02 / الاسم", - "headline": "سمِّ خادمك.", - "body": "اختياري. اختر تسمية تتعرّف عليها في المحور — مثل \"ماك العمل\" أو \"خادم المنزل\". يمكنك تغييرها لاحقًا في الإعدادات.", - "fieldLabel": "اسم الخادم", - "placeholder": "مثل: ماك العمل", - "cta": "متابعة", - "skip": "تخطّي الآن" + "skip": "تخطي", + "pairLater": "الاقتران لاحقاً" }, "connect": { "eyebrow": "> 02 / إقران", "headline": "اتصل ببيئة التشغيل.", "modeBlurb": "اختر كيف تريد تسليم المفاتيح للخادم.", + "connectivityHint": "هاتفك يصل إلى حاسوبك عبر نفس شبكة Wi‑Fi أو عبر VPN (مثل Tailscale) أو عبر عنوان نفق عام.", + "recommended": "موصى به", + "invalidUrl": "أدخل عنوان خادم كاملاً يبدأ بـ http:// أو https://.", + "contactSupport": "تحتاج مساعدة؟ تواصل مع الدعم", "scanQr": "مسح QR", "scanQrBody": "شغّل tb pair على خادمك لطباعة QR. أسرع طريقة، بدون كتابة.", "pasteCredentials": "الإدخال اليدوي", - "pasteCredentialsBody": "شغّل tb pair على خادمك، ثم الصق URL والرمز.", + "pasteCredentialsBody": "شغّل tb pair على خادمك، ثم الصق URL والرمز — أو رابط threadbase:// الكامل.", "qrEyebrow": "> 02 / إقران · QR", "qrHeadline": "امسح للإقران.", - "step1": "1. على خادمك، شغّل tb pair. سيُطبَع رمز QR في الطرفية.", - "step2": "2. اضغط على فتح الكاميرا بالأسفل. سيطلب Threadbase إذنًا لاستخدام الكاميرا — يُستخدم فقط لقراءة رمز QR.", "openCamera": "فتح الكاميرا", - "pairMyLaptop": "إقران حاسوبي المحمول", "ready": "✓ جاهز", - "footnote": "// على سطح مكتبك، شغّل tb pair لطباعة توكن.", - "manualSectionLabel": "على Mac الخاص بك", + "manualSectionLabel": "على جهازك", "manualSectionHint": "افتح Terminal وشغّل:", - "manualSectionPasteHint": "يطبع URL + رمزًا — الصق كليهما أدناه.", - "manualServerUrl": "عنوان الخادم", + "manualSectionPasteHint": "يطبع URL + رمزًا (أو رابط threadbase://) — الصق أدناه.", "manualToken": "الرمز", - "manualTokenPlaceholder": "الصق الرمز من مخرجات tb pair", "manualScanInstead": "مسح QR بدلًا من ذلك →", "manualEnterInstead": "استخدام الإدخال اليدوي بدلًا من ذلك", "connectIdle": "اتصال", "connectOk": "متصل", "connectErr": "إعادة المحاولة", "connectBusy": "…جارٍ الاتصال", - "connectErrHint": "انتهت صلاحية الرمز؟ شغّل tb pair مرة أخرى على Mac الخاص بك." + "connectErrHint": "انتهت صلاحية الرمز؟ شغّل tb pair مجدداً على جهازك.", + "backToOptions": "← خيارات أخرى", + "step1": "1. على خادمك، شغّل tb pair. سيُطبَع رمز QR في الطرفية.", + "step2": "2. اضغط على فتح الكاميرا بالأسفل. سيطلب Threadbase إذنًا لاستخدام الكاميرا — يُستخدم فقط لقراءة رمز QR.", + "step3": "3. وجّه هاتفك نحو رمز QR. الرمز صالح لمدة 3 دقائق؛ إن انتهت صلاحيته، شغّل tb pair مجدداً." }, "done": { "eyebrowPaired": "اكتمل الإقران", @@ -72,6 +50,8 @@ "headlineUnpaired": "أنت داخل.", "bodyPaired": "جهازك المحمول يستمع. افتح سيشن متى أردت.", "bodyUnpaired": "تخطَّ الآن — يمكنك توصيل بيئة تشغيل من الإعدادات في أي وقت.", + "pillPaired": "مقترن · {{host}} · {{port}}", + "pillPairedNamed": "مقترن · {{name}} · {{host}} · {{port}}", "pillUnpaired": "لا توجد بيئة تشغيل · قرن من الإعدادات", "ctaPaired": "ادخل Threadbase", "ctaUnpaired": "متابعة — سأقرن لاحقًا" @@ -83,30 +63,5 @@ "skipCta": "تخطي — سأراقب الكانبان", "enabledStatus": "مفعّل · alerts.threadbase.dev", "tapToAllow": "اضغط للسماح" - }, - "tour": { - "eyebrow": "> 04 / الجولة", - "eyebrowProgress": "> 04 / الجولة · {{current}}/{{total}}", - "dropIn": "أدخلني", - "nextConcept": "المفهوم التالي" - }, - "valueProp": { - "eyebrow": "// المظهر", - "eyebrowWhy": "> 01 / لماذا", - "headline": "حاسوبك المحمول هو بيئة التشغيل. هاتفك هو قمرة القيادة.", - "headlineMain": "حاسوبك المحمول هو بيئة التشغيل.", - "headlineMuted": "هاتفك هو قمرة القيادة.", - "headlineAccent": "vibe.", - "body": "يُبقي Threadbase وكلاءك أحياءً على الجهاز الذي يملك المعالج الرسومي والمفاتيح وشجرة الملفات — ويمنحك سطحًا هادئًا لقيادتهم من أي مكان.", - "cta": "إقران حاسوبي المحمول", - "labelYou": "أنت", - "labelPrompts": "برومبتات ▸", - "labelStdout": "◂ stdout", - "labelClaudeRun": "claude run" - }, - "theme": { - "eyebrow": "// المظهر", - "headlineLine1": "اختر", - "body": "اختر سمة ألوان. يمكنك دائمًا تغييرها لاحقًا في الإعدادات." } } diff --git a/locales/ar/pair.json b/locales/ar/pair.json index 78e01a0e..785d51bd 100644 --- a/locales/ar/pair.json +++ b/locales/ar/pair.json @@ -4,6 +4,9 @@ "permissionBody": "يحتاج Threadbase إلى الوصول للكاميرا لمسح رمز QR للإقران الذي يعرضه خادمك.", "allowCamera": "متابعة", "permissionHint": "الوصول إلى الكاميرا معطّل. افتح الإعدادات لتفعيله لـ Threadbase.", + "openSettings": "فتح الإعدادات", + "close": "إغلاق", + "contactSupport": "تحتاج مساعدة؟ تواصل مع الدعم", "exchanging": "جارٍ تبادل توكن الإقران…", "errorTitle": "فشل الإقران", "tryAgain": "حاول مجددًا", diff --git a/locales/ar/servers.json b/locales/ar/servers.json index 60fb6504..df51dd88 100644 --- a/locales/ar/servers.json +++ b/locales/ar/servers.json @@ -4,39 +4,26 @@ }, "status": { "connected": "متصل", - "disconnected": "غير متصل", - "connecting": "جارٍ الاتصال…", - "error": "خطأ في الاتصال" + "disconnected": "غير متصل" }, "action": { "add": "إضافة خادم", - "edit": "تعديل", - "remove": "إزالة الخادم", - "viewError": "عرض خطأ الاتصال", "connect": "اتصال", "details": "التفاصيل" }, "dialog": { "removeTitle": "إزالة الخادم", - "removeMessage": "هل أنت متأكد أنك تريد إزالة هذا الخادم؟", - "removeConfirm": "إزالة", - "removeDismiss": "إلغاء" + "removeMessage": "قطع الاتصال بـ {{server}}؟", + "removeConfirm": "إزالة" }, "form": { - "name": "الاسم", - "url": "عنوان URL", - "namePlaceholder": "خادمي", - "urlPlaceholder": "http://localhost:3000", "labelOptional": "التسمية (اختياري)", "serverUrl": "عنوان الخادم", "apiKey": "مفتاح API", "hint": "شغّل cch serve --tunnel --qr على جهاز Mac للحصول على عنوان قابل للمسح عبر QR." }, "manage": { - "title": "الخوادم", - "addServer": "إضافة خادم", - "addServerHint": "اتصل بنسخة tb-streamer", - "empty": "لم تتم إضافة أي خوادم بعد." + "addServer": "إضافة خادم" }, "filter": { "title": "عوامل التصفية", @@ -69,7 +56,6 @@ "rememberChoice": "تذكّر هذا الاختيار" }, "statusModal": { - "title": "حالة الخوادم", "close": "✕", "menuEdit": "تعديل", "menuRefresh": "تحديث", @@ -85,5 +71,134 @@ "label": "جارٍ فحص وفهرسة المحادثات…", "subtitle": "يقوم خادمك بالإحماء وبناء فهرس المحادثات للمرة الأولى. يحدث هذا مرة واحدة فقط — سيظهر السجل فور أن يصبح جاهزًا.", "progress": "{{scanned}} / {{total}} ملفات" + }, + "cacheAlert": { + "bannerTitle": "{{count}} من سجلات المحادثات مفقودة على {{server}}", + "title": "{{missing}} من أصل {{total}} من سجلات المحادثات مفقودة على {{server}}", + "backupHint": "يُنصح بالتحقق من نسخة احتياطية عبر Time Machine (أو ما يعادلها) قبل تنفيذ أي إجراء غير قابل للتراجع.", + "actionPruneAll": "حذف الكل", + "actionPruneSelected": "حذف المحدد", + "actionIgnore": "تجاهل", + "actionResetRescan": "إعادة التعيين وإعادة الفحص", + "confirmTitle": "هل أنت متأكد؟", + "confirmPruneAll": "سيؤدي هذا إلى إزالة جميع المحادثات المفقودة البالغ عددها {{count}} من الذاكرة المؤقتة بشكل دائم. لا يمكن التراجع عن هذا الإجراء.", + "confirmPruneSelected": "سيؤدي هذا إلى إزالة {{count}} من المحادثات المحددة من الذاكرة المؤقتة بشكل دائم. لا يمكن التراجع عن هذا الإجراء.", + "confirmResetRescan": "سيؤدي هذا إلى إعادة تعيين الذاكرة المؤقتة وإعادة فحص القرص. لا يمكن التراجع عن هذا الإجراء.", + "confirmCancel": "إلغاء", + "confirmProceed": "متابعة", + "selectAll": "تحديد الكل", + "selectNone": "إلغاء التحديد", + "successToast": "تم الحل. تم حفظ النسخة الاحتياطية في {{backupPath}}", + "successToastNoBackup": "تم الحل.", + "selectAtLeastOne": "حدد محادثة واحدة على الأقل للحذف." + }, + "claudeFlags": { + "title": "أعلام Claude CLI", + "description": "تُطبَّق على كل جلسة يبدأها هذا الخادم. تسري من الجلسة التالية.", + "notPersisted": "بدأ هذا الخادم بأعلام سطر الأوامر، لذا تسري التغييرات الآن لكنها تُعاد عند إعادة التشغيل.", + "extraArgsLabel": "وسائط إضافية", + "extraArgsUnsupported": "تُمرَّر كما هي دون تحقق. استخدمها على مسؤوليتك.", + "listHint": "مفصولة بفواصل", + "save": "حفظ الأعلام", + "confirmTitle": "تعطيل طلبات الإذن؟", + "confirmApprove": "موافقة", + "confirmContinue": "متابعة", + "dangerousExplanation": "سيشغّل كلود أوامر على هذا الجهاز دون سؤالك، في كل جلسة قادمة. وأي شخص يملك مفتاح API لهذا الخادم يحصل على القدرة نفسها.", + "flags": { + "permissionMode": { + "label": "وضع الأذونات", + "description": "مدى استئذان كلود قبل التنفيذ. أوضاع التجاوز تلغي كل تأكيد." + }, + "addDir": { + "label": "مجلدات إضافية", + "description": "مجلدات إضافية يمكن لكلود قراءتها وتعديلها خارج المشروع." + }, + "allowedTools": { + "label": "الأدوات المسموحة", + "description": "هذه الأدوات فقط تعمل دون سؤال. بديل أكثر أمانًا لوضع التجاوز." + }, + "disallowedTools": { + "label": "الأدوات المحظورة", + "description": "أدوات لن يشغلها كلود أبدًا على هذا الخادم." + }, + "maxBudgetUsd": { + "label": "حد الميزانية (USD)", + "description": "يحدّ إنفاق الجلسة الواحدة. مفيد عند تعطيل التأكيدات." + }, + "fallbackModel": { + "label": "النموذج البديل", + "description": "النموذج الذي يتم التبديل إليه عند عدم توفر الأساسي." + } + } + }, + "health": { + "heading": "صحة الخادم", + "subtitle": "فحوصات مستقلة من كل ستريمر مقترن. تتضمن الأعطال رمزًا وإجراءًا مقترحًا. لا تُدرج الأسرار.", + "screenTitle": "صحة الخادم", + "emptyTitle": "لا خوادم بعد", + "emptyBody": "اربط خادمًا أولاً، ثم عد هنا لفحص صحته.", + "loading": "جارٍ فحص الخادم…", + "loadFailed": "تعذّر تحميل صحة الخادم.", + "retry": "إعادة المحاولة", + "overall": "الإجمالي", + "generatedAt": "أُنشئ في {{at}}", + "copy": "نسخ التقرير", + "copied": "تم النسخ", + "copyFailed": "تعذّر نسخ التقرير.", + "share": "مشاركة التقرير", + "shareFailed": "تعذّر مشاركة التقرير.", + "unsupportedContract": "يُبلّغ الستريمر عن عقد تشخيص v{{version}}. يتوقع التطبيق v1 — قد تكون بعض الحقول ناقصة.", + "status": { + "ok": "سليم", + "degraded": "متراجع", + "failed": "فشل", + "unknown": "غير معروف" + }, + "checks": { + "streamer": "الستريمر", + "providerClaude": "Claude Code CLI", + "providerCodex": "Codex CLI", + "cache": "ذاكرة المحادثات", + "pty": "نظام PTY", + "filesystem": "مجلد سجل المزوّد" + }, + "remediation": { + "PROVIDER_NOT_INSTALLED": { + "title": "CLI المزوّد غير مثبت", + "action": "على جهاز Mac الذي يشغّل الستريمر، ثبّت CLI المزوّد وتأكد أنه في PATH، ثم أعد المحاولة." + }, + "PROVIDER_VERSION_UNVERIFIED": { + "title": "إصدار المزوّد غير موثّق", + "action": "حدّث CLI إلى إصدار وثّقه الستريمر، ثم أعد المحاولة." + }, + "DB_UNAVAILABLE": { + "title": "قاعدة البيانات غير متاحة", + "action": "أعد تشغيل الستريمر. إن استمر الأمر، افحص مساحة القرص وسجلات المضيف." + }, + "DB_MIGRATION_PENDING": { + "title": "ترحيل قاعدة بيانات معلّق", + "action": "رقِّ الستريمر وأعد تشغيله لتطبيق الترحيلات." + }, + "PTY_UNAVAILABLE": { + "title": "PTY غير متاح", + "action": "أعد تثبيت/بناء الستريمر لتحميل وحدة PTY، ثم أعد التشغيل." + }, + "CACHE_DEGRADED": { + "title": "ذاكرة المحادثات متراجعة", + "action": "افتح الخوادم ← عالج تنبيه سلامة الذاكرة على هذا المضيف، ثم أعد المحاولة." + }, + "CLOCK_SKEWED": { + "title": "انحراف الساعة", + "action": "اضبط ساعة المضيف (وقت تلقائي). تنتهي رموز الاقتران خلال دقائق." + }, + "FS_SCOPE_MISSING": { + "title": "مجلد السجل مفقود", + "action": "تأكد أن المزوّد أنشأ مجلد السجل على المضيف، ثم أعد المحاولة." + }, + "NONE": { + "title": "لا إجراء مطلوب", + "action": "" + } + } } } diff --git a/locales/ar/sessions.json b/locales/ar/sessions.json index b5d900ac..ec9a310d 100644 --- a/locales/ar/sessions.json +++ b/locales/ar/sessions.json @@ -8,46 +8,24 @@ }, "filter": { "label": "تصفية وفرز", - "all": "الكل", - "active": "نشط", - "waiting": "قيد الانتظار", - "completed": "مكتمل", - "failed": "فشل" - }, - "sort": { - "label": "فرز", - "newest": "الأحدث", - "oldest": "الأقدم", - "az": "أ–ي" + "all": "الكل" }, "list": { - "sessionCount_zero": "لا سيشنات", - "sessionCount_one": "سيشن واحد", - "sessionCount_two": "سيشنان", - "sessionCount_few": "{{count}} سيشنات", - "sessionCount_many": "{{count}} سيشنًا", - "sessionCount_other": "{{count}} سيشن", "empty": "لا توجد سيشنات", "emptySubtitle": "ابدأ سيشن Claude Code\nلرؤيته هنا", "noResults": "لا توجد نتائج", "noResultsSubtitle": "لا شيء يطابق \"{{query}}\"", - "emptyProjects": "لا توجد مشاريع بعد" + "serverEmpty": "لا توجد سيشنات على هذا الخادم", + "serverEmptySubtitle": "ابدأ سيشن هنا أو بدّل الخادم.", + "serverOffline": "الخادم غير متاح", + "serverOfflineSubtitle": "يُعرض الخوادم الأخرى. أعد المحاولة عندما يعود المضيف.", + "serverWarming": "الخادم قيد التجهيز", + "serverWarmingSubtitle": "سيظهر السجل عند انتهاء الفهرسة." }, "card": { - "status": { - "active": "نشط", - "waiting": "في انتظار الإدخال", - "completed": "اكتمل السيشن", - "failed": "فشل السيشن", - "starting": "جارٍ البدء…", - "stopping": "جارٍ الإيقاف…" - }, - "connectedTo": "{{server}}", "copyId": "نسخ معرّف السيشن", "sendInput": "إرسال إدخال", "cancel": "إلغاء السيشن", - "remove": "إزالة السيشن", - "viewError": "عرض خطأ الاتصال", "prompts_zero": "لا برومبتات", "prompts_one": "برومبت واحد", "prompts_two": "برومبتان", @@ -55,16 +33,6 @@ "prompts_many": "{{count}} برومبتًا", "prompts_other": "{{count}} برومبت" }, - "takeover": { - "prompt": "عميل آخر يتحكّم في هذا السيشن", - "takeControl": "تولَّ التحكم", - "dismiss": "تجاهل" - }, - "directory": { - "empty": "مجلد فارغ", - "loadFailed": "تعذّر تحميل المجلدات", - "browsingNotConfigured": "التصفّح غير مُهيّأ" - }, "hub": { "seeAll_zero": "لا محادثات →", "seeAll_one": "عرض المحادثة الوحيدة →", @@ -77,13 +45,7 @@ "msgs_two": "رسالتان", "msgs_few": "{{count}} رسائل", "msgs_many": "{{count}} رسالة", - "msgs_other": "{{count}} رسالة", - "prompts_zero": "لا برومبتات", - "prompts_one": "برومبت واحد", - "prompts_two": "برومبتان", - "prompts_few": "{{count}} برومبتات", - "prompts_many": "{{count}} برومبتًا", - "prompts_other": "{{count}} برومبت" + "msgs_other": "{{count}} رسالة" }, "loading": { "title": "جارٍ تحميل السيشنات", @@ -108,12 +70,6 @@ "headerIdle_many": "خامل · {{count}}", "headerIdle_other": "خامل · {{count}}" }, - "rename": { - "title": "إعادة تسمية السيشن", - "placeholder": "اسم السيشن", - "cancel": "إلغاء", - "save": "حفظ" - }, "preview": { "liveUnavailable": "معاينة السيشن المباشر غير متاحة — افتح السيشن لرؤية الطرفية المباشرة.", "noMessages": "لا توجد رسائل بعد.", @@ -140,5 +96,25 @@ "sync": { "cachedData": "عرض بيانات مخزنة مؤقتًا", "cachedDataSyncing": "عرض بيانات مخزنة مؤقتًا — جارٍ المزامنة…" + }, + "status": { + "running": "قيد التشغيل", + "waiting": "في الانتظار", + "idle": "خامل", + "externalLive": "خارجي", + "historical": "سجل", + "resumed": "مُستأنف", + "onHold": "معلّق", + "completed": "مكتمل", + "failed": "فشل", + "unavailablePath": "غير متاح", + "unavailableWorktree": "لا يوجد worktree", + "stale": "قديم", + "livePill": "LIVE", + "externalPill": "EXTERNAL" + }, + "capability": { + "observeOnly": "للمشاهدة فقط", + "liveControl": "تحكم مباشر" } } diff --git a/locales/ar/settings.json b/locales/ar/settings.json index d79577d4..8cf0a585 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -21,7 +21,6 @@ "chatViewNote": "أظهر السيشنات المباشرة كفقاعات دردشة بدلًا من مخرجات الطرفية الخام. بيتا — قد تحتوي على أخطاء ولا تعمل دائمًا.", "betaBadge": "بيتا" }, - "comingSoonBadge": "قريبًا", "permissions": { "camera": "الكاميرا", "cameraDesc": "مسح رمز QR للاقتران بالخوادم", @@ -41,7 +40,6 @@ "theme": "السمة", "dark": "داكن", "light": "فاتح", - "system": "النظام", "glassVariations": "أنماط الزجاج", "layout": "التخطيط", "layoutTree": "شجرة", @@ -76,7 +74,10 @@ }, "help": { "restartOnboarding": "إعادة تشغيل الإعداد التمهيدي", - "helpSupport": "المساعدة والدعم" + "helpSupport": "المساعدة والدعم", + "serverHealth": "صحة الخادم", + "pairedDevices": "الأجهزة المقترنة", + "backupRestore": "نسخ احتياطي واستعادة" }, "crashReporting": { "title": "مشاركة تقارير الأعطال المجهولة", @@ -86,10 +87,7 @@ "testThrow": "إطلاق استثناء غير معالج", "testCrashConfirmTitle": "إرسال عطل اختباري؟", "testCrashConfirmMessage": "يؤدي هذا إلى إطلاق خطأ اختباري للتأكد من أن الإبلاغ يعمل. متاح فقط في إصدارات التطوير.", - "testCrashSend": "إرسال عطل اختباري", - "noticeTitle": "الإبلاغ عن الأعطال متاح", - "noticeMessage": "يمكن لـ Threadbase الآن إرسال تقارير أعطال منقّاة إذا وافقت. يبقى معطّلاً حتى تقوم بتفعيله في الإعدادات. لا يتم أبدًا تضمين المطالبات أو مخرجات الطرفية أو محتوى الجلسات.", - "noticeDismiss": "فهمت" + "testCrashSend": "إرسال عطل اختباري" }, "servers": { "scanQr": "امسح QR لإضافة خادم" @@ -98,19 +96,10 @@ "title": "تسمية السيشن", "autoNameFromMessage": "تسمية تلقائية من أول رسالة", "aiGeneratedNames": "أسماء سيشن مُولّدة بالذكاء الاصطناعي", - "askOnCreateNote": "أظهر مطالبة لتسمية السيشن عند بدء واحد جديد.", - "askOnExitNote": "اقترح تسمية السيشن عند مغادرته إن لم تتم تسميته بعد. يُعطَّل عند إيقاف مطالبة الإنشاء.", "autoNameNote": "عيّن اسم السيشن من أول كلمات رسالتك الأولى. بلا ذكاء اصطناعي، وبلا توكنات.", "aiGeneratedNote": "استخدم نموذج ذكاء اصطناعي لإنشاء اسم ذي معنى. يستهلك توكنات من مفتاح API المُهيّأ لديك." }, - "quickAccess": { - "title": "الوصول السريع", - "favorites": "المفضلة", - "recentSessions": "السيشنات الأخيرة", - "popularProjects": "المشاريع الشائعة" - }, "language": { - "label": "اللغة", "english": "English", "hebrew": "עברית", "arabic": "العربية", @@ -140,5 +129,132 @@ "previewLast": "الأخيرة", "previewOff": "إيقاف" }, - "restartTour": "إعادة تشغيل جولة التطبيق" + "notificationHealth": { + "screenTitle": "صحة الإشعارات", + "heading": "صحة الإشعارات", + "subtitle": "حالة تسجيل الدفع لكل خادم من الستريمر. لا تُعرض الرموز.", + "emptyTitle": "لا خوادم بعد", + "emptyBody": "اربط خادمًا أولاً، ثم افحص صحة التسليم هنا.", + "loading": "جارٍ فحص صحة الدفع…", + "loadFailed": "تعذّر تحميل صحة الدفع.", + "retry": "إعادة المحاولة", + "reregister": "إعادة التسجيل", + "reregistering": "جارٍ التسجيل…", + "reregistered": "أُعيد تسجيل الرمز مع هذا الخادم.", + "reregisterFailed": "فشلت إعادة التسجيل. تحقق من إذن الإشعارات وحاول مجددًا.", + "quietPreview": "ساعات الهدوء (هذا الجهاز)", + "quietActive": "ساعات الهدوء نشطة الآن — تُكبت التنبيهات محليًا.", + "quietInactive": "ساعات الهدوء متوقفة أو غير نشطة الآن.", + "quietWindow": "{{from}} – {{to}}", + "storeAvailable": "مخزن الدفع متاح على هذا الخادم.", + "storeUnavailable": "مخزن الدفع غير متاح على هذا الخادم.", + "noTokens": "لا رموز دفع مسجّلة لهذا الخادم بعد.", + "platform": "Platform", + "registeredAt": "Registered", + "lastSuccess": "Last success", + "lastFailure": "Last failure", + "failureCode": "Failure code", + "failureStreak": "Failure streak", + "state": { + "never-delivered": "Never delivered", + "healthy": "Healthy", + "failing": "Failing", + "dead": "Dead", + "revoked": "Revoked" + }, + "hintNeverDelivered": "Registered, but no successful delivery yet (detection vs delivery).", + "hintDelivery": "Delivery failures are accumulating on the server — re-register or check Expo credentials.", + "hintRevoked": "This token was revoked on the server.", + "hintHealthy": "Recent deliveries succeeded.", + "openRow": "صحة التسليم", + "reregisterNeedsPermission": "إذن الإشعارات متوقف — فعّله في إعدادات النظام ثم أعد المحاولة.", + "reregisterNeedsDevice": "تعذّر الحصول على رمز دفع (المحاكيات لا تسجّل). جرّب على جهاز حقيقي." + }, + "pairedDevices": { + "screenTitle": "الأجهزة المقترنة", + "heading": "الأجهزة المقترنة", + "subtitle": "الأجهزة المسجّلة على هذا الستريمر. لا تُعرض بيانات الاعتماد — الأسماء والقدرات والنشاط فقط.", + "emptyTitle": "لا خوادم بعد", + "emptyBody": "اقترن بخادم أولاً، ثم أدِر أجهزته من هنا.", + "loading": "جارٍ تحميل الأجهزة…", + "loadFailed": "تعذّر تحميل الأجهزة المقترنة.", + "retry": "إعادة المحاولة", + "storeUnavailable": "سجل الأجهزة غير متاح على هذا الخادم.", + "noDevices": "لا أجهزة مسجّلة بعد. الاقتران من هذا التطبيق (أو غيره) يُنشئ جهازاً.", + "controlNote": "الأجهزة ذات التحكم بالجلسة يمكنها قيادة الوكيل. الإلغاء يزيل بيانات اعتماد ذلك الجهاز فقط — بقية الأجهزة تبقى.", + "hasControl": "يمكن لهذا الجهاز التحكم بالجلسات (بدء، إرسال إدخال، مقاطعة).", + "thisDevice": "هذا الجهاز", + "unnamed": "جهاز {{id}}", + "noCapabilities": "لا قدرات مدرجة", + "createdAt": "أُنشئ {{at}}", + "lastSeen": "آخر ظهور {{at}}", + "revokedAt": "أُلغي {{at}}", + "revokedSection": "ملغاة", + "revoke": "إلغاء", + "revokeTitle": "إلغاء الجهاز؟", + "revokeBody": "إلغاء «{{name}}»؟ سيفقد الوصول عند الطلب التالي.", + "revokeThisBody": "إلغاء بيانات اعتماد جهاز هذا الهاتف؟ مفتاح المالك المشترك في هذا التطبيق يبقى؛ يُبطَل رمز الجهاز فقط.", + "revokeConfirm": "إلغاء", + "revoked": "تم إلغاء الجهاز.", + "alreadyRevoked": "الجهاز ملغى مسبقاً.", + "revokeFailed": "تعذّر إلغاء الجهاز.", + "capability": { + "historyRead": "قراءة السجل", + "sessionControl": "تحكم الجلسة", + "fsBrowse": "تصفح الملفات", + "fsUpload": "رفع الملفات", + "notifications": "الإشعارات", + "admin": "إدارة" + } + }, + "backup": { + "screenTitle": "نسخ احتياطي واستعادة", + "heading": "نسخ احتياطي واستعادة", + "subtitle": "Export and restore Threadbase project metadata for this server.", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then export or restore metadata here.", + "metadataOnly": "هذا الأرشيف بيانات وصفية فقط (هوية المشاريع والمسارات). المحادثات غير مضمّنة — تعيش في سجل المزوّد على القرص وتعود عند إعادة المسح.", + "exportSection": "Export", + "restoreSection": "Restore", + "export": "Export now", + "exporting": "Exporting…", + "exported": "Exported {{count}} projects.", + "exportFailed": "Export failed.", + "copy": "Copy JSON", + "share": "Share", + "copied": "Archive copied to clipboard.", + "copyFailed": "Could not copy archive.", + "shareFailed": "Could not share archive.", + "manifestTitle": "Export summary", + "manifestHost": "Host: {{host}}", + "manifestVersion": "Streamer: {{version}}", + "manifestProjects": "Projects: {{count}}", + "manifestCreated": "Created: {{at}}", + "pasteHint": "Paste an archive JSON below, then dry-run before applying.", + "pastePlaceholder": "{ \"manifest\": …, \"projects\": […] }", + "pasteRequired": "Paste an archive JSON first.", + "invalidJson": "Paste is not valid JSON.", + "invalidArchive": "JSON is not a recognized backup archive.", + "pathMapHint": "Optional path rewrite for a machine move (prefix from → to).", + "pathFrom": "From path prefix", + "pathTo": "To path prefix", + "dryRun": "Dry-run restore", + "planning": "Planning…", + "dryRunReady": "Dry-run complete — review the plan, then apply.", + "dryRunFailed": "Dry-run failed.", + "unexpectedApply": "Unexpected apply response from dry-run.", + "apply": "Apply restore", + "applyTitle": "Apply restore?", + "applyBody": "This rewrites project identity on the server. Conversations are not imported from the archive.", + "applyConfirm": "Apply", + "applyFailed": "Apply failed.", + "applied": "Restore applied ({{count}} projects written).", + "conflict": "Restore has path conflicts — resolve them before applying.", + "conflictDetail": "A path is claimed by a different project id. Applying would break one side’s links.", + "conflictRow": "Conflict {{path}} (incoming {{incoming}} vs existing {{existing}})", + "planTitle": "Restore plan", + "planSummary": "Create {{create}} · update {{update}} · conflict {{conflict}}", + "createRow": "Create: {{name}}", + "updateRow": "Update: {{name}}" + } } diff --git a/locales/ar/shared.json b/locales/ar/shared.json index 8cc7012e..b6f047f7 100644 --- a/locales/ar/shared.json +++ b/locales/ar/shared.json @@ -19,25 +19,6 @@ "pinToFavorites": "تثبيت في المفضلة", "cancel": "إلغاء", "loadMore": "+ {{count}} إضافية", - "emptyFavorites": "لا توجد مفضلة بعد — اضغط مطوّلًا على عنصر لتثبيته.", - "pairServer": "أقرِن خادمًا لرؤية {{tab}}.", - "loading": "جارٍ التحميل…", - "loadFailed": "تعذّر التحميل — اضغط لإعادة المحاولة.", - "nothing": "لا شيء بعد.", - "favorites": "المفضلة", - "popular": "الشائعة", - "recents": "الأخيرة" - }, - "conversationInfo": { - "title": "معلومات المحادثة", - "id": "المعرّف", - "titleLabel": "العنوان", - "projectPath": "مسار المشروع", - "messageCount": "عدد الرسائل", - "lastActivity": "آخر نشاط" - }, - "session": { - "active": "نشط", - "gotIt": "فهمت" + "emptyFavorites": "لا توجد مفضلة بعد — اضغط مطوّلًا على عنصر لتثبيته." } } diff --git a/locales/ar/terminal.json b/locales/ar/terminal.json index 6b6296c7..f4644097 100644 --- a/locales/ar/terminal.json +++ b/locales/ar/terminal.json @@ -1,7 +1,4 @@ { - "header": { - "session": "السيشن" - }, "connection": { "reconnectingTitle": "جارٍ إعادة الاتصال…", "reconnectingMessage": "انقطع الاتصال — قد يكون المحتوى أدناه قديمًا.", @@ -9,38 +6,24 @@ "stalledMessage": "متصل، لكن لم يصل أي إخراج منذ فترة." }, "action": { - "copyId": "نسخ معرّف السيشن", "sendInput": "إرسال إدخال", - "cancel": "إلغاء السيشن", - "stop": "إيقاف السيشن", - "more": "المزيد" + "stop": "إيقاف السيشن" }, "voice": { "start": "بدء الإملاء", - "stop": "إيقاف الإملاء", - "permissionDeniedTitle": "تم رفض إذن الميكروفون", - "permissionDeniedBody": "يحتاج Threadbase إلى الوصول للميكروفون لإملاء البرومبتات. فعّله في الإعدادات." + "stop": "إيقاف الإملاء" }, "dialog": { "cancelTitle": "إلغاء السيشن", "cancelMessage": "هل أنت متأكد أنك تريد إلغاء هذا السيشن؟", "cancelConfirm": "إلغاء السيشن", - "cancelDismiss": "إبقاؤه يعمل", "removeTitle": "إزالة السيشن", - "removeMessage": "هل أنت متأكد أنك تريد إزالة هذا السيشن؟", "removeConfirm": "إزالة", - "removeDismiss": "إبقاء", "stopTitle": "إيقاف السيشن", - "stopMessage": "هل تريد إيقاف هذا السيشن؟ سيتم مقاطعة Claude.", - "stopConfirm": "إيقاف", - "stopDismiss": "إبقاؤه يعمل", "stopFailed": "تعذّر إيقاف السيشن. تحقق من الاتصال وحاول مرة أخرى." }, "status": { - "connecting": "جارٍ الاتصال…", - "reconnecting": "جارٍ إعادة الاتصال…", "disconnected": "غير متصل", - "waking": "جارٍ الإيقاظ…", "starting": "جارٍ بدء السيشن…", "stuckTitle": "لا يزال قيد البدء…", "stuckBody": "لم يصل السيشن إلى نقطة الاستعداد بعد. يمكنك مشاهدة مخرجات الطرفية له مباشرة بدلاً من الانتظار.", @@ -48,11 +31,12 @@ "waitMore": "الانتظار أكثر" }, "input": { - "placeholder": "أرسل رسالة…", - "send": "إرسال" + "placeholder": "أرسل رسالة…" }, "session": { "notFound": "السيشن غير موجود", + "notFoundBody": "لم يعد هذا السيشن موجودًا على الخادم. ربما انتهى أو تمت إزالته.", + "backToHub": "العودة إلى المركز", "ended": "انتهت الجلسة", "endedBody": "انتهت هذه الجلسة دون إنتاج أي مخرجات.", "failedToStart": "فشل بدء السيشن", @@ -70,10 +54,13 @@ "prompts_few": "{{count}} برومبتات", "prompts_many": "{{count}} برومبتًا", "prompts_other": "{{count}} برومبت", - "tabTerminal": "الطرفية", - "tabChat": "الدردشة" + "viewModeTerminal": "طرفية", + "viewModeChat": "محادثة", + "rawFallbackBanner": "عرض الطرفية الخام — تطبيع المحادثة غير مؤكد لهذا البث.", + "ptyActiveFallbackBanner": "مخرجات الطرفية المباشرة نشطة قبل توفر رسائل المحادثة.", + "rawModeNote": "وضع الطرفية الخام — تم اكتشاف تسلسلات غير مدعومة؛ المخرجات غير مفلترة." }, - "banner": { - "streamingInfo": "تُبَثّ استدعاءات الأدوات والمخرجات هنا في الوقت الفعلي. اكتب متابعة بالأسفل بينما لا يزال Claude يعمل." + "a11y": { + "line": "السطر {{n}}: {{text}}" } } diff --git a/locales/en/browse.json b/locales/en/browse.json index 274ea091..86004878 100644 --- a/locales/en/browse.json +++ b/locales/en/browse.json @@ -2,6 +2,10 @@ "nav": { "back": "‹ Back", "recentDirs": "Recent directories ({{count}})", + "displayAll": "Display all", + "allRecentDirs": "Recent directories", + "searchRecentDirs": "Search locations", + "noMatchingDirs": "No matching locations", "newFolder": "New Folder", "create": "Create", "startSession": "Start Session Here" @@ -22,5 +26,15 @@ "retryHint": "Pull down to retry", "startFailed": "Failed to start session", "startTimeout": "The server didn't respond in time. The session may still be starting — check your sessions list before trying again." + }, + "provider": { + "unavailable": "This provider’s CLI is not installed on the server. Choose another provider or install it on the host.", + "noStructuredQuestions": "This provider has no structured question menus — you’ll answer in the raw terminal.", + "observeOnly": "Live control is unavailable for this provider; sessions are observe-only.", + "warning": { + "provider_not_found": "Provider CLI not found on the server PATH.", + "version_undetectable": "Could not read the installed provider version — compatibility is unverified, and it may behave differently.", + "version_unverified": "Installed provider version is unverified against this streamer — it may behave differently." + } } } diff --git a/locales/en/common.json b/locales/en/common.json index e144460f..9a7dd3df 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3,41 +3,21 @@ "cancel": "Cancel", "confirm": "Confirm", "save": "Save", - "done": "Done", "back": "Back", "close": "Close", "retry": "Retry", - "remove": "Remove", - "apply": "Apply", - "skip": "Skip", - "create": "Create", - "export": "Export", - "copy": "Copy", - "paste": "Paste", - "openSettings": "Open Settings" + "apply": "Apply" }, "error": { - "generic": "Something went wrong", - "loadFailed": "Unable to load", - "connectionFailed": "Connection failed" - }, - "state": { - "loading": "Loading…", - "empty": "Nothing here yet", - "noResults": "No results" + "loadFailed": "Unable to load" }, "search": { - "placeholder": "Search…", - "clear": "Clear search" + "placeholder": "Search…" }, "nav": { "top": "↑ Top", "bottom": "↓ Bottom", - "backArrow": "‹ Back" - }, - "time": { - "now": "now", - "yesterday": "Yesterday" + "copyAll": "Copy all" }, "biometricLock": { "title": "Threadbase is locked", @@ -57,5 +37,9 @@ "enable": "Turn on", "notNow": "Not now" } + }, + "renderError": { + "title": "Couldn’t render this item", + "message": "Showing a raw fallback instead of crashing the screen." } } diff --git a/locales/en/conversation.json b/locales/en/conversation.json index 34722acb..c3785f3b 100644 --- a/locales/en/conversation.json +++ b/locales/en/conversation.json @@ -5,14 +5,12 @@ }, "list": { "loadingMore": "Loading more…", - "empty": "No messages in this conversation.", - "msgs_one": "{{count}} msg", - "msgs_other": "{{count}} msgs", - "tokens": "{{count}}k tokens" + "empty": "No messages in this conversation." }, "error": { "loadFailed": "Couldn't load conversation", "notFound": "This conversation is no longer available on the server.", + "back": "Back to hub", "resumeTimeout": "The server didn't respond in time. The session may still be starting — check your sessions list before trying again." }, "unavailable": { @@ -20,6 +18,27 @@ "worktreeRemoved": "This conversation ran in a git worktree that no longer exists. You can read the history, but it can't be resumed.", "cannotResume": "Can't resume" }, + "resume": { + "resuming": "Resuming…", + "collisionTitle": "Resume this conversation?", + "collisionMessage": "This conversation may still be open in a terminal on your computer — {{reasons}}. Resuming it here could interfere with that session.", + "confirm": "Resume anyway", + "failed": "Couldn't resume this conversation. Please try again.", + "reason": { + "jsonl_mtime": "its history was updated moments ago", + "process_argv": "a matching terminal process may still be running", + "process_cwd": "a process may still be using its project folder", + "unknown": "another program may still be using it" + }, + "takeOver": "Take over", + "takeOverFailed": "Couldn't take over that session. Please try again." + }, + "live": { + "indicator": "Live", + "paused": "Paused", + "pause": "Pause live updates", + "resume": "Resume live updates" + }, "action": { "export": "Export", "copyPatch": "Copy patch", @@ -41,12 +60,36 @@ "matchCount": "{{current}} of {{total}}", "previousMatch": "Previous match", "nextMatch": "Next match", - "clearSearch": "Clear search" + "clearSearch": "Clear search", + "placeholder": "Search in conversation…", + "open": "Search in conversation" }, "slowLoading": { "title1": "Untangling a long thread…", "title2": "Messages are fashionably late…", "title3": "Your messages hit some traffic…", "message": "Wasn't expecting them to be this heavy. We're loading as fast as we can." + }, + "review": { + "title": "Review changes", + "open": "Open review", + "summary": "{{files}} files · +{{added}} / −{{removed}}", + "incompleteWarning": "Built from conversation Edit/Write tools — may be incomplete vs real git status.", + "oversizedWarning": "Some diffs are truncated for performance. Copy the handoff packet for desktop review.", + "filterAll": "All", + "kindEdited": "Edited", + "kindWritten": "Written", + "kindDiff": "Diff", + "kindUnknown": "Other", + "searchPlaceholder": "Filter files…", + "empty": "No file changes found in this conversation yet.", + "selectFile": "Select a file to view its diff.", + "copyHandoff": "Copy for desktop", + "sendNote": "Send note to agent", + "sendNoteTitle": "Send review note?", + "sendNoteBody": "Posts a compact file list into the live session input.", + "cancel": "Cancel", + "send": "Send", + "fileMeta": "{{kind}} · +{{added}} / −{{removed}}" } } diff --git a/locales/en/feedback.json b/locales/en/feedback.json index 0d26f53e..8543a67d 100644 --- a/locales/en/feedback.json +++ b/locales/en/feedback.json @@ -63,16 +63,12 @@ "success": { "title": "Feedback committed", "message": "Thanks — your report made it into the queue with no merge conflicts.", + "viaSentry": "Sent via Sentry crash-reporting service", + "viaEmail": "Sent via email", "done": "Done" }, "error": { - "title": "Push failed", - "message": "We couldn't send your report. Your feedback is still here, so you can try again.", - "tryAgain": "Try again" - }, - "offline": { - "title": "Looks like the network branch is offline", - "message": "Reconnect and try again, or copy the report for later." + "message": "We couldn't send your report. Your feedback is still here, so you can try again." }, "screenshotError": { "title": "That screenshot won't fit", diff --git a/locales/en/onboarding.json b/locales/en/onboarding.json index 5a219a60..04e6c1a9 100644 --- a/locales/en/onboarding.json +++ b/locales/en/onboarding.json @@ -1,69 +1,47 @@ { "welcome": { - "title": "Welcome to Threadbase", - "subtitle": "Connect to your Claude Code sessions", "eyebrow": "// AMBIENT CODING", "headline": "Pull a thread.", "headlineAccent": "Watch it weave.", "body": "A remote control for Claude Code, on the device you actually carry.", "cta": "Get started" }, - "addServer": { - "title": "Add a Server", - "subtitle": "Enter your Threadbase server URL to get started" - }, - "browse": { - "title": "Browse", - "empty": "No files", - "emptySubtitle": "This directory is empty" - }, - "conversation": { - "title": "Conversation", - "empty": "No messages", - "emptySubtitle": "No conversation history yet" - }, "shell": { "back": "Back", - "skip": "Skip" - }, - "serverName": { - "eyebrow": "> 02 / NAME", - "headline": "Name your server.", - "body": "Optional. Pick a label you'll recognise in the Hub — like \"Work Mac\" or \"Home Server\". You can change it later in Settings.", - "fieldLabel": "Server name", - "placeholder": "e.g. Work Mac", - "cta": "Continue", - "skip": "Skip for now" + "skip": "Skip", + "pairLater": "Pair later" }, "connect": { "eyebrow": "> 02 / PAIR", "headline": "Connect a runtime.", "modeBlurb": "Pick how you want to hand the server its keys.", + "connectivityHint": "Your phone reaches your computer over the same Wi‑Fi, a VPN (for example Tailscale), or a public tunnel URL.", "scanQr": "Scan QR", "scanQrBody": "Run tb pair on your server to print a QR. Fastest, no typing.", + "recommended": "Recommended", "pasteCredentials": "Type / paste manually", - "pasteCredentialsBody": "Run tb pair on your server, then paste the URL and token.", + "pasteCredentialsBody": "Run tb pair on your server, then paste the URL + token — or the full threadbase:// link.", "qrEyebrow": "> 02 / PAIR · QR", "qrHeadline": "Scan to pair.", - "step1": "1. On your server, run tb pair. A QR will print to the terminal.", - "step2": "2. Tap Open camera below. Threadbase will ask permission to use the camera — that's only used to read the QR.", "openCamera": "Open camera", - "pairMyLaptop": "Pair my laptop", "ready": "✓ ready", - "footnote": "// On your desktop, run tb pair to print a token.", - "manualSectionLabel": "On your Mac", + "manualSectionLabel": "On your computer", "manualSectionHint": "Open Terminal and run:", - "manualSectionPasteHint": "It prints a URL + token — paste both below.", - "manualServerUrl": "Server URL", + "manualSectionPasteHint": "It prints a URL + token (or a threadbase:// link) — paste below.", "manualToken": "Token", - "manualTokenPlaceholder": "paste token from tb pair output", "manualScanInstead": "Scan a QR instead →", "manualEnterInstead": "Use manual entry instead", + "invalidUrl": "Enter a full http:// or https:// server address.", + "contactSupport": "Need help? Contact support", "connectIdle": "Connect", "connectOk": "Connected", "connectErr": "Retry", "connectBusy": "…connecting", - "connectErrHint": "Token expired? Run tb pair again on your Mac." + "connectErrHint": "Token expired? Run tb pair again on your computer.", + "backToOptions": "← Other options", + "step1": "1. On your server, run tb pair. A QR will print to the terminal.", + "step2": "2. Tap Open camera below. Threadbase will ask permission to use the camera — that's only used to read the QR.", + "step3": "3. Point your phone at the QR. The pair token is valid for 3 minutes; if it expires, just run tb pair again." }, "done": { "eyebrowPaired": "HANDSHAKE COMPLETE", @@ -72,6 +50,8 @@ "headlineUnpaired": "You're in.", "bodyPaired": "Your laptop is listening. Open a session whenever the mood strikes.", "bodyUnpaired": "Skip it for now — you can connect a runtime from Settings whenever you're ready.", + "pillPaired": "paired · {{host}} · {{port}}", + "pillPairedNamed": "paired · {{name}} · {{host}} · {{port}}", "pillUnpaired": "no runtime paired · pair from Settings", "ctaPaired": "Enter Threadbase", "ctaUnpaired": "Continue — I'll pair later" @@ -83,30 +63,5 @@ "skipCta": "Skip — I'll watch the kanban", "enabledStatus": "ENABLED · alerts.threadbase.dev", "tapToAllow": "TAP TO ALLOW" - }, - "tour": { - "eyebrow": "> 04 / TOUR", - "eyebrowProgress": "> 04 / TOUR · {{current}}/{{total}}", - "dropIn": "Drop me in", - "nextConcept": "Next concept" - }, - "valueProp": { - "eyebrow": "// APPEARANCE", - "eyebrowWhy": "> 01 / WHY", - "headline": "Your laptop is the runtime. Your phone is the cockpit.", - "headlineMain": "Your laptop is the runtime.", - "headlineMuted": "Your phone is the cockpit.", - "headlineAccent": "vibe.", - "body": "Threadbase keeps your agents alive on the box that has the GPU, the keys, and the file tree — and gives you a calm surface to drive them from anywhere.", - "cta": "Pair my laptop", - "labelYou": "YOU", - "labelPrompts": "prompts ▸", - "labelStdout": "◂ stdout", - "labelClaudeRun": "claude run" - }, - "theme": { - "eyebrow": "// APPEARANCE", - "headlineLine1": "Pick your", - "body": "Choose a color theme. You can always change it later in Settings." } } diff --git a/locales/en/pair.json b/locales/en/pair.json index e186df58..b79c3811 100644 --- a/locales/en/pair.json +++ b/locales/en/pair.json @@ -4,9 +4,12 @@ "permissionBody": "Threadbase needs camera access to scan a pairing QR code shown by your server.", "allowCamera": "Continue", "permissionHint": "Camera access is disabled. Open Settings to enable it for Threadbase.", + "openSettings": "Open Settings", + "close": "Close", "exchanging": "Exchanging pair token…", "errorTitle": "Pairing failed", "tryAgain": "Try again", + "contactSupport": "Need help? Contact support", "reticleHint": "Point at the QR shown by your server", "errors": { "generic": "Pairing failed.", diff --git a/locales/en/servers.json b/locales/en/servers.json index dc79f5be..f8b5ca2e 100644 --- a/locales/en/servers.json +++ b/locales/en/servers.json @@ -4,39 +4,26 @@ }, "status": { "connected": "Connected", - "disconnected": "Disconnected", - "connecting": "Connecting…", - "error": "Connection Error" + "disconnected": "Disconnected" }, "action": { "add": "Add Server", - "edit": "Edit", - "remove": "Remove Server", - "viewError": "View connection error", "connect": "Connect", "details": "Details" }, "dialog": { "removeTitle": "Remove Server", - "removeMessage": "Are you sure you want to remove this server?", - "removeConfirm": "Remove", - "removeDismiss": "Cancel" + "removeMessage": "Disconnect from {{server}}?", + "removeConfirm": "Remove" }, "form": { - "name": "Name", - "url": "URL", - "namePlaceholder": "My Server", - "urlPlaceholder": "http://localhost:3000", "labelOptional": "Label (optional)", "serverUrl": "Server URL", "apiKey": "API Key", "hint": "Run cch serve --tunnel --qr on your Mac to get a QR-scannable URL." }, "manage": { - "title": "Servers", - "addServer": "Add Server", - "addServerHint": "Connect to a tb-streamer instance", - "empty": "No servers added yet." + "addServer": "Add Server" }, "filter": { "title": "Filters", @@ -69,7 +56,6 @@ "rememberChoice": "Remember this choice" }, "statusModal": { - "title": "Servers Status", "close": "✕", "menuEdit": "Edit", "menuRefresh": "Refresh", @@ -85,5 +71,134 @@ "label": "Scanning and indexing conversations…", "subtitle": "Your server is warming up and building its conversation index for the first time. This only happens once — history will appear as soon as it's ready.", "progress": "{{scanned}} / {{total}} files" + }, + "cacheAlert": { + "bannerTitle": "{{count}} conversation histories are missing on {{server}}", + "title": "{{missing}} of {{total}} conversation histories are missing on {{server}}", + "backupHint": "Consider checking a Time Machine (or equivalent) backup before doing anything destructive.", + "actionPruneAll": "Prune All", + "actionPruneSelected": "Prune Selected", + "actionIgnore": "Ignore", + "actionResetRescan": "Reset & Rescan", + "confirmTitle": "Are you sure?", + "confirmPruneAll": "This permanently removes all {{count}} missing conversations from the cache. This cannot be undone.", + "confirmPruneSelected": "This permanently removes {{count}} selected conversations from the cache. This cannot be undone.", + "confirmResetRescan": "This resets the cache and rescans from disk. This cannot be undone.", + "confirmCancel": "Cancel", + "confirmProceed": "Proceed", + "selectAll": "Select All", + "selectNone": "Select None", + "successToast": "Resolved. Backup saved at {{backupPath}}", + "successToastNoBackup": "Resolved.", + "selectAtLeastOne": "Select at least one conversation to prune." + }, + "claudeFlags": { + "title": "Claude CLI flags", + "description": "Applied to every session this server starts. Takes effect on the next session.", + "notPersisted": "This server was started with command-line flags, so changes apply now but reset on restart.", + "extraArgsLabel": "Extra arguments", + "extraArgsUnsupported": "Passed through as-is and not validated. Use at your own risk.", + "listHint": "Comma-separated", + "save": "Save flags", + "confirmTitle": "Disable permission prompts?", + "confirmApprove": "Approve", + "confirmContinue": "Continue", + "dangerousExplanation": "Claude will run commands on this machine without asking you first, for every future session. Anyone with this server's API key gets the same power.", + "flags": { + "permissionMode": { + "label": "Permission mode", + "description": "How much Claude asks before acting. Bypass modes remove every confirmation." + }, + "addDir": { + "label": "Additional directories", + "description": "Extra folders Claude may read and edit, beyond the project." + }, + "allowedTools": { + "label": "Allowed tools", + "description": "Only these tools run without asking. A safer alternative to bypass mode." + }, + "disallowedTools": { + "label": "Blocked tools", + "description": "Tools Claude may never run on this server." + }, + "maxBudgetUsd": { + "label": "Budget limit (USD)", + "description": "Caps what one session can spend. Useful when prompts are disabled." + }, + "fallbackModel": { + "label": "Fallback model", + "description": "Model to switch to if the main one is unavailable." + } + } + }, + "health": { + "heading": "Server health", + "subtitle": "Independent checks from each paired streamer. Failures include a code and what to do next. Secrets are never included.", + "screenTitle": "Server health", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then return here to inspect its health.", + "loading": "Checking server…", + "loadFailed": "Could not load server health.", + "retry": "Retry", + "overall": "Overall", + "generatedAt": "Generated {{at}}", + "copy": "Copy report", + "copied": "Copied", + "copyFailed": "Could not copy the report.", + "share": "Share report", + "shareFailed": "Could not share the report.", + "unsupportedContract": "This streamer reports diagnostics contract v{{version}}. This app expects v1 — some fields may be missing.", + "status": { + "ok": "Healthy", + "degraded": "Degraded", + "failed": "Failed", + "unknown": "Unknown" + }, + "checks": { + "streamer": "Streamer", + "providerClaude": "Claude Code CLI", + "providerCodex": "Codex CLI", + "cache": "Conversation cache", + "pty": "PTY subsystem", + "filesystem": "Provider history folder" + }, + "remediation": { + "PROVIDER_NOT_INSTALLED": { + "title": "Provider CLI missing", + "action": "On the Mac that runs the streamer, install the provider CLI and confirm it is on PATH, then retry." + }, + "PROVIDER_VERSION_UNVERIFIED": { + "title": "Provider version unverified", + "action": "Update the provider CLI to a version the streamer has verified, then retry." + }, + "DB_UNAVAILABLE": { + "title": "Database unavailable", + "action": "Restart the streamer. If this persists, check disk space and streamer logs on the host." + }, + "DB_MIGRATION_PENDING": { + "title": "Database migration pending", + "action": "Upgrade and restart the streamer so pending migrations can apply." + }, + "PTY_UNAVAILABLE": { + "title": "PTY unavailable", + "action": "Reinstall or rebuild the streamer so the native PTY module loads, then restart it." + }, + "CACHE_DEGRADED": { + "title": "Conversation cache degraded", + "action": "Open Servers → resolve the cache integrity alert on this host, then retry." + }, + "CLOCK_SKEWED": { + "title": "Clock skew detected", + "action": "Set the host clock correctly (automatic time). Pairing tokens expire in minutes, so large skew breaks pairing." + }, + "FS_SCOPE_MISSING": { + "title": "History folder missing", + "action": "Ensure the provider has created its projects/history directory on the host, then retry." + }, + "NONE": { + "title": "No action needed", + "action": "" + } + } } } diff --git a/locales/en/sessions.json b/locales/en/sessions.json index 8fad225d..e7c974ad 100644 --- a/locales/en/sessions.json +++ b/locales/en/sessions.json @@ -8,62 +8,32 @@ }, "filter": { "label": "Filter & Sort", - "all": "All", - "active": "Active", - "waiting": "Waiting", - "completed": "Completed", - "failed": "Failed" - }, - "sort": { - "label": "Sort", - "newest": "Newest", - "oldest": "Oldest", - "az": "A–Z" + "all": "All" }, "list": { - "sessionCount_one": "{{count}} session", - "sessionCount_other": "{{count}} sessions", "empty": "No sessions", "emptySubtitle": "Start a Claude Code\nsession to see it here", "noResults": "No results", "noResultsSubtitle": "Nothing matched \"{{query}}\"", - "emptyProjects": "No projects yet" + "serverEmpty": "No sessions on this server", + "serverEmptySubtitle": "Start a session here or switch servers.", + "serverOffline": "Server unreachable", + "serverOfflineSubtitle": "Showing other servers. Retry when this host is back.", + "serverWarming": "Server is warming up", + "serverWarmingSubtitle": "History will appear when indexing finishes." }, "card": { - "status": { - "active": "Active", - "waiting": "Waiting for Input", - "completed": "Session Completed", - "failed": "Session Failed", - "starting": "Starting…", - "stopping": "Stopping…" - }, - "connectedTo": "{{server}}", "copyId": "Copy Session ID", "sendInput": "Send Input", "cancel": "Cancel Session", - "remove": "Remove Session", - "viewError": "View connection error", "prompts_one": "{{count}} prompt", "prompts_other": "{{count}} prompts" }, - "takeover": { - "prompt": "Another client is controlling this session", - "takeControl": "Take Control", - "dismiss": "Dismiss" - }, - "directory": { - "empty": "Empty directory", - "loadFailed": "Unable to load directories", - "browsingNotConfigured": "Browsing not configured" - }, "hub": { "seeAll_one": "See all {{count}} conversation →", "seeAll_other": "See all {{count}} conversations →", "msgs_one": "{{count}} msg", - "msgs_other": "{{count}} msgs", - "prompts_one": "{{count}} prompt", - "prompts_other": "{{count}} prompts" + "msgs_other": "{{count}} msgs" }, "loading": { "title": "Loading sessions", @@ -80,12 +50,6 @@ "headerIdle_one": "IDLE · {{count}}", "headerIdle_other": "IDLE · {{count}}" }, - "rename": { - "title": "Rename session", - "placeholder": "Session name", - "cancel": "Cancel", - "save": "Save" - }, "preview": { "liveUnavailable": "Live session preview not available — open the session to see the live terminal.", "noMessages": "No messages yet.", @@ -112,5 +76,25 @@ "sync": { "cachedData": "Showing cached data", "cachedDataSyncing": "Showing cached data — syncing…" + }, + "status": { + "running": "Running", + "waiting": "Waiting", + "idle": "Idle", + "externalLive": "External", + "historical": "History", + "resumed": "Resumed", + "onHold": "On hold", + "completed": "Completed", + "failed": "Failed", + "unavailablePath": "Unavailable", + "unavailableWorktree": "Worktree gone", + "stale": "Stale", + "livePill": "LIVE", + "externalPill": "EXTERNAL" + }, + "capability": { + "observeOnly": "Observe only", + "liveControl": "Live control" } } diff --git a/locales/en/settings.json b/locales/en/settings.json index 9c14689f..9b75359e 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -21,7 +21,6 @@ "chatViewNote": "Show live sessions as chat bubbles instead of raw terminal output. Beta — may be buggy and not always work.", "betaBadge": "Beta" }, - "comingSoonBadge": "Coming soon", "permissions": { "camera": "Camera", "cameraDesc": "QR code scanning to pair with servers", @@ -41,7 +40,6 @@ "theme": "Theme", "dark": "Dark", "light": "Light", - "system": "System", "glassVariations": "Glass variations", "layout": "Layout", "layoutTree": "Tree", @@ -76,7 +74,10 @@ }, "help": { "restartOnboarding": "Restart onboarding", - "helpSupport": "Help & Support" + "helpSupport": "Help & Support", + "serverHealth": "Server health", + "pairedDevices": "Paired devices", + "backupRestore": "Backup & restore" }, "crashReporting": { "title": "Share anonymous crash reports", @@ -86,10 +87,7 @@ "testThrow": "Throw uncaught exception", "testCrashConfirmTitle": "Send a test crash?", "testCrashConfirmMessage": "This raises a test error so you can confirm reporting works. Only available in development builds.", - "testCrashSend": "Send test crash", - "noticeTitle": "Crash reporting is available", - "noticeMessage": "Threadbase can now send sanitized crash reports if you opt in. It stays off until you turn it on in Settings. No prompts, terminal output, or session content are ever included.", - "noticeDismiss": "Got it" + "testCrashSend": "Send test crash" }, "servers": { "scanQr": "Scan QR to add server" @@ -98,19 +96,10 @@ "title": "Session Naming", "autoNameFromMessage": "Auto-name from first message", "aiGeneratedNames": "AI-generated session names", - "askOnCreateNote": "Show a prompt to name the session when you start a new one.", - "askOnExitNote": "Suggest naming the session when you leave it, if it hasn't been named yet. Suppressed when the creation prompt is disabled.", "autoNameNote": "Set the session name from the first words of your first message. No AI, no tokens.", "aiGeneratedNote": "Use an AI model to generate a meaningful name. Uses tokens from your configured API key." }, - "quickAccess": { - "title": "Quick Access", - "favorites": "Favorites", - "recentSessions": "Recent Sessions", - "popularProjects": "Popular Projects" - }, "language": { - "label": "Language", "english": "English", "hebrew": "עברית", "arabic": "العربية", @@ -140,5 +129,132 @@ "previewLast": "Last", "previewOff": "Off" }, - "restartTour": "Restart app tour" + "notificationHealth": { + "screenTitle": "Notification health", + "heading": "Notification health", + "subtitle": "Per-server push registration state from the streamer. Tokens are never shown.", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then check notification delivery health here.", + "loading": "Checking push health…", + "loadFailed": "Could not load push health.", + "retry": "Retry", + "reregister": "Re-register", + "reregistering": "Registering…", + "reregistered": "Token re-registered with this server.", + "reregisterFailed": "Re-registration failed. Check notification permission and try again.", + "quietPreview": "Quiet hours (this device)", + "quietActive": "Quiet hours are active now — alerts are suppressed locally.", + "quietInactive": "Quiet hours are off or not active right now.", + "quietWindow": "{{from}} – {{to}}", + "storeAvailable": "Push store is available on this server.", + "storeUnavailable": "Push store is unavailable on this server (registration cannot persist).", + "noTokens": "No push tokens registered for this server yet.", + "platform": "Platform", + "registeredAt": "Registered", + "lastSuccess": "Last success", + "lastFailure": "Last failure", + "failureCode": "Failure code", + "failureStreak": "Failure streak", + "state": { + "never-delivered": "Never delivered", + "healthy": "Healthy", + "failing": "Failing", + "dead": "Dead", + "revoked": "Revoked" + }, + "hintNeverDelivered": "Registered, but no successful delivery yet (detection vs delivery).", + "hintDelivery": "Delivery failures are accumulating on the server — re-register or check Expo credentials.", + "hintRevoked": "This token was revoked on the server.", + "hintHealthy": "Recent deliveries succeeded.", + "openRow": "Delivery health", + "reregisterNeedsPermission": "Notifications permission is off — enable it in system settings, then retry.", + "reregisterNeedsDevice": "Could not get a push token (simulators cannot register). Try on a physical device." + }, + "pairedDevices": { + "screenTitle": "Paired devices", + "heading": "Paired devices", + "subtitle": "Devices registered with this streamer. Credentials are never shown — only names, capabilities, and activity.", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then manage its devices here.", + "loading": "Loading devices…", + "loadFailed": "Could not load paired devices.", + "retry": "Retry", + "storeUnavailable": "Device registry is unavailable on this server.", + "noDevices": "No devices registered yet. Pair from this app (or another) to mint one.", + "controlNote": "Devices with session control can drive the agent. Revoking removes that device’s credential only — other devices keep working.", + "hasControl": "This device can control sessions (start, send input, interrupt).", + "thisDevice": "This device", + "unnamed": "Device {{id}}", + "noCapabilities": "No capabilities listed", + "createdAt": "Created {{at}}", + "lastSeen": "Last seen {{at}}", + "revokedAt": "Revoked {{at}}", + "revokedSection": "Revoked", + "revoke": "Revoke", + "revokeTitle": "Revoke device?", + "revokeBody": "Revoke “{{name}}”? It will lose access on the next request.", + "revokeThisBody": "Revoke this phone’s device credential? The shared owner key on this app still works; only the per-device token is invalidated.", + "revokeConfirm": "Revoke", + "revoked": "Device revoked.", + "alreadyRevoked": "Device was already revoked.", + "revokeFailed": "Could not revoke device.", + "capability": { + "historyRead": "Read history", + "sessionControl": "Session control", + "fsBrowse": "Browse files", + "fsUpload": "Upload files", + "notifications": "Notifications", + "admin": "Admin" + } + }, + "backup": { + "screenTitle": "Backup & restore", + "heading": "Backup & restore", + "subtitle": "Export and restore Threadbase project metadata for this server.", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then export or restore metadata here.", + "metadataOnly": "This archive is metadata only (project identity and paths). Conversations are not included — they live in provider history on disk and return when the streamer rescans.", + "exportSection": "Export", + "restoreSection": "Restore", + "export": "Export now", + "exporting": "Exporting…", + "exported": "Exported {{count}} projects.", + "exportFailed": "Export failed.", + "copy": "Copy JSON", + "share": "Share", + "copied": "Archive copied to clipboard.", + "copyFailed": "Could not copy archive.", + "shareFailed": "Could not share archive.", + "manifestTitle": "Export summary", + "manifestHost": "Host: {{host}}", + "manifestVersion": "Streamer: {{version}}", + "manifestProjects": "Projects: {{count}}", + "manifestCreated": "Created: {{at}}", + "pasteHint": "Paste an archive JSON below, then dry-run before applying.", + "pastePlaceholder": "{ \"manifest\": …, \"projects\": […] }", + "pasteRequired": "Paste an archive JSON first.", + "invalidJson": "Paste is not valid JSON.", + "invalidArchive": "JSON is not a recognized backup archive.", + "pathMapHint": "Optional path rewrite for a machine move (prefix from → to).", + "pathFrom": "From path prefix", + "pathTo": "To path prefix", + "dryRun": "Dry-run restore", + "planning": "Planning…", + "dryRunReady": "Dry-run complete — review the plan, then apply.", + "dryRunFailed": "Dry-run failed.", + "unexpectedApply": "Unexpected apply response from dry-run.", + "apply": "Apply restore", + "applyTitle": "Apply restore?", + "applyBody": "This rewrites project identity on the server. Conversations are not imported from the archive.", + "applyConfirm": "Apply", + "applyFailed": "Apply failed.", + "applied": "Restore applied ({{count}} projects written).", + "conflict": "Restore has path conflicts — resolve them before applying.", + "conflictDetail": "A path is claimed by a different project id. Applying would break one side’s links.", + "conflictRow": "Conflict {{path}} (incoming {{incoming}} vs existing {{existing}})", + "planTitle": "Restore plan", + "planSummary": "Create {{create}} · update {{update}} · conflict {{conflict}}", + "createRow": "Create: {{name}}", + "updateRow": "Update: {{name}}" + } } diff --git a/locales/en/shared.json b/locales/en/shared.json index d22316da..7349a097 100644 --- a/locales/en/shared.json +++ b/locales/en/shared.json @@ -19,25 +19,6 @@ "pinToFavorites": "Pin to Favorites", "cancel": "Cancel", "loadMore": "+ {{count}} more", - "emptyFavorites": "No favorites yet — long-press an item to pin it.", - "pairServer": "Pair a server to see {{tab}}.", - "loading": "Loading…", - "loadFailed": "Couldn't load — tap to retry.", - "nothing": "Nothing yet.", - "favorites": "Favorites", - "popular": "Popular", - "recents": "Recents" - }, - "conversationInfo": { - "title": "Conversation Info", - "id": "ID", - "titleLabel": "TITLE", - "projectPath": "PROJECT PATH", - "messageCount": "MESSAGE COUNT", - "lastActivity": "LAST ACTIVITY" - }, - "session": { - "active": "Active", - "gotIt": "Got it" + "emptyFavorites": "No favorites yet — long-press an item to pin it." } } diff --git a/locales/en/terminal.json b/locales/en/terminal.json index 7a72a1e8..270cdbe7 100644 --- a/locales/en/terminal.json +++ b/locales/en/terminal.json @@ -1,7 +1,4 @@ { - "header": { - "session": "Session" - }, "connection": { "reconnectingTitle": "Reconnecting…", "reconnectingMessage": "Connection lost — the content below may be stale.", @@ -9,38 +6,24 @@ "stalledMessage": "Connected, but no output has arrived for a while." }, "action": { - "copyId": "Copy Session ID", "sendInput": "Send Input", - "cancel": "Cancel Session", - "stop": "Stop session", - "more": "More" + "stop": "Stop session" }, "voice": { "start": "Start dictation", - "stop": "Stop dictation", - "permissionDeniedTitle": "Microphone permission denied", - "permissionDeniedBody": "Threadbase needs microphone access to dictate prompts. Enable it in Settings." + "stop": "Stop dictation" }, "dialog": { "cancelTitle": "Cancel Session", "cancelMessage": "Are you sure you want to cancel this session?", "cancelConfirm": "Cancel Session", - "cancelDismiss": "Keep Running", "removeTitle": "Remove Session", - "removeMessage": "Are you sure you want to remove this session?", "removeConfirm": "Remove", - "removeDismiss": "Keep", "stopTitle": "Stop session", - "stopMessage": "Stop this session? Claude will be interrupted.", - "stopConfirm": "Stop", - "stopDismiss": "Keep Running", "stopFailed": "Couldn't stop the session. Check your connection and try again." }, "status": { - "connecting": "Connecting…", - "reconnecting": "Reconnecting…", "disconnected": "Disconnected", - "waking": "Waking up…", "starting": "Starting session…", "stuckTitle": "Still starting…", "stuckBody": "The session hasn't reached its prompt yet. You can watch its console output live instead of waiting.", @@ -48,11 +31,12 @@ "waitMore": "Wait more" }, "input": { - "placeholder": "Send a message…", - "send": "Send" + "placeholder": "Send a message…" }, "session": { "notFound": "Session not found", + "notFoundBody": "This session is no longer on the server. It may have ended or been cleaned up.", + "backToHub": "Back to hub", "ended": "Session ended", "endedBody": "This session ended without producing any output.", "failedToStart": "Session failed to start", @@ -66,10 +50,13 @@ "overtake": "Overtake", "prompts_one": "{{count}} prompt", "prompts_other": "{{count}} prompts", - "tabTerminal": "Terminal", - "tabChat": "Chat" - }, - "banner": { - "streamingInfo": "Tool calls and output stream here in real time. Type a follow-up below while Claude is still working." + "viewModeTerminal": "Terminal", + "viewModeChat": "Chat", + "rawFallbackBanner": "Showing raw terminal — chat normalization is uncertain for this stream.", + "ptyActiveFallbackBanner": "Live terminal output is active before conversation messages are available.", + "rawModeNote": "Raw terminal mode — unsupported sequences detected; output is unfiltered." + }, + "a11y": { + "line": "Line {{n}}: {{text}}" } } diff --git a/locales/he/browse.json b/locales/he/browse.json index 165b5399..f7eb0269 100644 --- a/locales/he/browse.json +++ b/locales/he/browse.json @@ -2,6 +2,10 @@ "nav": { "back": "› חזרה", "recentDirs": "תיקיות אחרונות ({{count}})", + "displayAll": "הצג הכל", + "allRecentDirs": "תיקיות אחרונות", + "searchRecentDirs": "חיפוש מיקומים", + "noMatchingDirs": "אין מיקומים תואמים", "newFolder": "תיקייה חדשה", "create": "צור", "startSession": "התחל סשן כאן" @@ -22,5 +26,15 @@ "retryHint": "משוך למטה כדי לנסות שוב", "startFailed": "הפעלת הסשן נכשלה", "startTimeout": "השרת לא הגיב בזמן. הסשן עדיין עשוי להתחיל — בדוק ברשימת הסשנים שלך לפני שתנסה שוב." + }, + "provider": { + "unavailable": "ה־CLI של הספק אינו מותקן בשרת. בחר ספק אחר או התקן במארח.", + "noStructuredQuestions": "לספק זה אין תפריטי שאלות מובנים — תענה במסוף הגולמי.", + "observeOnly": "שליטה חיה אינה זמינה לספק זה; הסשנים לצפייה בלבד.", + "warning": { + "provider_not_found": "ה־CLI של הספק לא נמצא ב־PATH של השרת.", + "version_undetectable": "לא ניתן לקרוא את גרסת הספק — התאימות אינה מאומתת.", + "version_unverified": "גרסת הספק מחוץ לטווח שאימת הסטרימר." + } } } diff --git a/locales/he/common.json b/locales/he/common.json index 028cc43e..fd33b855 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3,41 +3,21 @@ "cancel": "ביטול", "confirm": "אישור", "save": "שמירה", - "done": "סיום", "back": "חזרה", "close": "סגירה", "retry": "נסה שוב", - "remove": "הסרה", - "apply": "החל", - "skip": "דלג", - "create": "צור", - "export": "ייצוא", - "copy": "העתק", - "paste": "הדבק", - "openSettings": "פתח הגדרות" + "apply": "החל" }, "error": { - "generic": "משהו השתבש", - "loadFailed": "נכשל בטעינה", - "connectionFailed": "החיבור נכשל" - }, - "state": { - "loading": "טוען…", - "empty": "אין כאן כלום עדיין", - "noResults": "אין תוצאות" + "loadFailed": "נכשל בטעינה" }, "search": { - "placeholder": "חיפוש…", - "clear": "נקה חיפוש" + "placeholder": "חיפוש…" }, "nav": { "top": "↑ למעלה", "bottom": "↓ למטה", - "backArrow": "› חזרה" - }, - "time": { - "now": "עכשיו", - "yesterday": "אתמול" + "copyAll": "העתק הכל" }, "biometricLock": { "title": "Threadbase נעול", @@ -57,5 +37,9 @@ "enable": "הפעל", "notNow": "לא עכשיו" } + }, + "renderError": { + "title": "לא ניתן להציג פריט זה", + "message": "מוצג גיבוי גולמי במקום קריסת המסך." } } diff --git a/locales/he/conversation.json b/locales/he/conversation.json index c729e69b..bcd8867a 100644 --- a/locales/he/conversation.json +++ b/locales/he/conversation.json @@ -5,14 +5,12 @@ }, "list": { "loadingMore": "טוען עוד…", - "empty": "אין הודעות בשיחה זו.", - "msgs_one": "הודעה אחת", - "msgs_other": "{{count}} הודעות", - "tokens": "{{count}}k טוקנים" + "empty": "אין הודעות בשיחה זו." }, "error": { "loadFailed": "לא ניתן לטעון שיחה", "notFound": "שיחה זו כבר אינה זמינה בשרת.", + "back": "חזרה למרכז", "resumeTimeout": "השרת לא הגיב בזמן. הסשן עדיין עשוי להתחיל — בדוק ברשימת הסשנים שלך לפני שתנסה שוב." }, "unavailable": { @@ -20,6 +18,27 @@ "worktreeRemoved": "שיחה זו רצה ב‑worktree של git שכבר אינו קיים. ניתן לקרוא את ההיסטוריה, אך לא ניתן לחדש אותה.", "cannotResume": "לא ניתן לחדש" }, + "resume": { + "resuming": "ממשיך…", + "collisionTitle": "להמשיך את השיחה הזו?", + "collisionMessage": "ייתכן שהשיחה הזו עדיין פתוחה בטרמינל במחשב שלך — {{reasons}}. המשך כאן עלול להפריע לאותו סשן.", + "confirm": "המשך בכל זאת", + "failed": "לא ניתן היה להמשיך את השיחה. נסה שוב.", + "reason": { + "jsonl_mtime": "ההיסטוריה שלה עודכנה לפני רגע", + "process_argv": "ייתכן שתהליך טרמינל תואם עדיין פועל", + "process_cwd": "ייתכן שתהליך עדיין משתמש בתיקיית הפרויקט שלה", + "unknown": "ייתכן שתוכנה אחרת עדיין משתמשת בה" + }, + "takeOver": "השתלט", + "takeOverFailed": "לא ניתן היה להשתלט על הסשן הזה. נסה שוב." + }, + "live": { + "indicator": "חי", + "paused": "מושהה", + "pause": "השהה עדכונים חיים", + "resume": "חדש עדכונים חיים" + }, "action": { "export": "ייצוא", "copyPatch": "העתק תיקון", @@ -41,12 +60,36 @@ "matchCount": "{{current}} מתוך {{total}}", "previousMatch": "התאמה קודמת", "nextMatch": "התאמה הבאה", - "clearSearch": "נקה חיפוש" + "clearSearch": "נקה חיפוש", + "placeholder": "חיפוש בשיחה…", + "open": "חיפוש בשיחה" }, "slowLoading": { "title1": "מתירים שרשור ארוך…", "title2": "ההודעות מאחרות באלגנטיות…", "title3": "ההודעות נתקעו בפקק…", "message": "לא ציפינו שהן יהיו כל כך כבדות. אנחנו טוענים הכי מהר שאפשר." + }, + "review": { + "title": "סקירת שינויים", + "open": "פתח סקירה", + "summary": "{{files}} קבצים · +{{added}} / −{{removed}}", + "incompleteWarning": "נבנה מכלי Edit/Write בשיחה — ייתכן שאינו מלא מול git status.", + "oversizedWarning": "חלק מה-diff קוצצו לביצועים. העתיקו את החבילה לסקירה בשולחן העבודה.", + "filterAll": "הכל", + "kindEdited": "נערך", + "kindWritten": "נכתב", + "kindDiff": "Diff", + "kindUnknown": "אחר", + "searchPlaceholder": "סינון קבצים…", + "empty": "עדיין אין שינויי קבצים בשיחה זו.", + "selectFile": "בחרו קובץ כדי לראות את ה-diff.", + "copyHandoff": "העתק לשולחן העבודה", + "sendNote": "שלח הערה לסוכן", + "sendNoteTitle": "לשלוח הערת סקירה?", + "sendNoteBody": "שולח רשימת קבצים מקוצרת לקלט הסשן החי.", + "cancel": "ביטול", + "send": "שלח", + "fileMeta": "{{kind}} · +{{added}} / −{{removed}}" } } diff --git a/locales/he/feedback.json b/locales/he/feedback.json index 3cc1283f..c9f719da 100644 --- a/locales/he/feedback.json +++ b/locales/he/feedback.json @@ -63,16 +63,12 @@ "success": { "title": "המשוב נקלט", "message": "תודה — הדיווח שלכם נכנס לתור ללא התנגשויות מיזוג.", + "viaSentry": "נשלח באמצעות שירות דיווח הקריסות Sentry", + "viaEmail": "נשלח באימייל", "done": "סיום" }, "error": { - "title": "השליחה נכשלה", - "message": "לא הצלחנו לשלוח את הדיווח. המשוב שלכם עדיין כאן, אז אפשר לנסות שוב.", - "tryAgain": "נסו שוב" - }, - "offline": { - "title": "נראה שהרשת במצב לא מקוון", - "message": "התחברו מחדש ונסו שוב, או העתיקו את הדיווח למועד מאוחר יותר." + "message": "לא הצלחנו לשלוח את הדיווח. המשוב שלכם עדיין כאן, אז אפשר לנסות שוב." }, "screenshotError": { "title": "צילום המסך גדול מדי", diff --git a/locales/he/onboarding.json b/locales/he/onboarding.json index b825f541..19bb74ca 100644 --- a/locales/he/onboarding.json +++ b/locales/he/onboarding.json @@ -1,69 +1,47 @@ { "welcome": { - "title": "ברוכים הבאים ל-Threadbase", - "subtitle": "התחבר לסשני Claude Code שלך", "eyebrow": "// AMBIENT CODING", "headline": "משוך חוט.", "headlineAccent": "צפה בו נארג.", "body": "שלט רחוק ל-Claude Code, על המכשיר שאתה באמת נושא איתך.", "cta": "בוא נתחיל" }, - "addServer": { - "title": "הוסף שרת", - "subtitle": "הזן את כתובת שרת Threadbase שלך כדי להתחיל" - }, - "browse": { - "title": "עיון", - "empty": "אין קבצים", - "emptySubtitle": "תיקייה זו ריקה" - }, - "conversation": { - "title": "שיחה", - "empty": "אין הודעות", - "emptySubtitle": "עדיין אין היסטוריית שיחה" - }, "shell": { "back": "חזרה", - "skip": "דלג" - }, - "serverName": { - "eyebrow": "> 02 / שם", - "headline": "תן שם לשרת שלך.", - "body": "אופציונלי. בחר תווית שתזהה במרכז — כמו \"Mac עבודה\" או \"שרת בית\". אתה יכול לשנות זאת מאוחר יותר בהגדרות.", - "fieldLabel": "שם שרת", - "placeholder": "למשל Mac עבודה", - "cta": "המשך", - "skip": "דלג לעת עתה" + "skip": "דלג", + "pairLater": "אצמד אחר כך" }, "connect": { "eyebrow": "> 02 / זיווג", "headline": "חבר סביבת ריצה.", "modeBlurb": "בחר איך אתה רוצה למסור לשרת את המפתחות שלו.", + "connectivityHint": "הטלפון מגיע למחשב דרך אותו Wi‑Fi, VPN (למשל Tailscale) או כתובת מנהרה ציבורית.", + "recommended": "מומלץ", + "invalidUrl": "הזינו כתובת שרת מלאה שמתחילה ב־http:// או https://.", + "contactSupport": "צריכים עזרה? פנו לתמיכה", "scanQr": "סרוק QR", "scanQrBody": "הרץ tb pair על השרת שלך כדי להדפיס QR. הכי מהיר, בלי הקלדה.", "pasteCredentials": "הקלד / הדבק באופן ידני", - "pasteCredentialsBody": "הרץ tb pair על השרת שלך, ואז הדבק את ה-URL והטוקן.", + "pasteCredentialsBody": "הרץ tb pair על השרת שלך, ואז הדבק את ה-URL והטוקן — או את קישור threadbase:// המלא.", "qrEyebrow": "> 02 / זיווג · QR", "qrHeadline": "סרוק כדי לזווג.", - "step1": "1. על השרת שלך, הרץ tb pair. QR יודפס לטרמינל.", - "step2": "2. הקש על פתח מצלמה למטה. Threadbase תבקש הרשאה להשתמש במצלמה — היא משמשת רק לקריאת ה-QR.", "openCamera": "פתח מצלמה", - "pairMyLaptop": "זווג את המחשב הנייד שלי", "ready": "✓ מוכן", - "footnote": "// על שולחן העבודה שלך, הרץ tb pair כדי להדפיס טוקן.", - "manualSectionLabel": "על ה-Mac שלך", + "manualSectionLabel": "במחשב שלך", "manualSectionHint": "פתח טרמינל והרץ:", - "manualSectionPasteHint": "הוא מדפיס URL + טוקן — הדבק את שניהם למטה.", - "manualServerUrl": "כתובת שרת", + "manualSectionPasteHint": "הוא מדפיס URL + טוקן (או קישור threadbase://) — הדבק למטה.", "manualToken": "טוקן", - "manualTokenPlaceholder": "הדבק טוקן מהפלט של tb pair", "manualScanInstead": "סרוק QR במקום →", "manualEnterInstead": "השתמש בהזנה ידנית במקום", "connectIdle": "התחבר", "connectOk": "מחובר", "connectErr": "נסה שוב", "connectBusy": "…מתחבר", - "connectErrHint": "הטוקן פג תוקף? הרץ tb pair שוב על ה-Mac שלך." + "connectErrHint": "פג תוקף הטוקן? הרץ tb pair שוב במחשב.", + "backToOptions": "← אפשרויות אחרות", + "step1": "1. על השרת שלך, הרץ tb pair. QR יודפס לטרמינל.", + "step2": "2. הקש על פתח מצלמה למטה. Threadbase תבקש הרשאה להשתמש במצלמה — היא משמשת רק לקריאת ה-QR.", + "step3": "3. כוון את הטלפון ל-QR. הטוקן תקף ל-3 דקות; אם פג, הרץ tb pair שוב." }, "done": { "eyebrowPaired": "לחיצת היד הושלמה", @@ -72,30 +50,12 @@ "headlineUnpaired": "אתה בפנים.", "bodyPaired": "המחשב הנייד שלך מאזין. פתח סשן כשתרצה.", "bodyUnpaired": "דלג לעת עתה — תוכל לחבר סביבת ריצה מההגדרות בכל עת.", + "pillPaired": "מחובר · {{host}} · {{port}}", + "pillPairedNamed": "מחובר · {{name}} · {{host}} · {{port}}", "pillUnpaired": "אין סביבת ריצה מחוברת · חבר מהגדרות", "ctaPaired": "כנס ל-Threadbase", "ctaUnpaired": "המשך — אחבר מאוחר יותר" }, - "tour": { - "eyebrow": "> 04 / סיור", - "eyebrowProgress": "> 04 / סיור · {{current}}/{{total}}", - "dropIn": "הכנס אותי", - "nextConcept": "קונספט הבא" - }, - "valueProp": { - "eyebrow": "// מראה", - "eyebrowWhy": "> 01 / למה", - "headline": "המחשב הנייד שלך הוא סביבת הריצה. הטלפון שלך הוא תא הטייס.", - "headlineMain": "המחשב הנייד שלך הוא סביבת הריצה.", - "headlineMuted": "הטלפון שלך הוא תא הטייס.", - "headlineAccent": "vibe.", - "body": "Threadbase שומר על הסוכנים שלך חיים על המכונה שיש לה את ה-GPU, את המפתחות ואת עץ הקבצים — ונותן לך משטח רגוע לנהוג אותם מכל מקום.", - "cta": "זווג את המחשב הנייד שלי", - "labelYou": "אתה", - "labelPrompts": "פרומפטים ▸", - "labelStdout": "◂ stdout", - "labelClaudeRun": "claude run" - }, "notifications": { "allowTitle": "התראות דחיפה", "body": "Threadbase שולח לך הודעה כשסשן צריך החלטה, מסיים ריצה, או נתקל בשגיאה — הדברים שכדאי לדעת בלי לפקח על טרמינל.", @@ -103,10 +63,5 @@ "skipCta": "דלג — אצפה בקנבן", "enabledStatus": "מופעל · alerts.threadbase.dev", "tapToAllow": "הקש כדי לאפשר" - }, - "theme": { - "eyebrow": "// מראה", - "headlineLine1": "בחר את ה", - "body": "בחר ערכת צבעים. אתה תמיד יכול לשנות זאת מאוחר יותר בהגדרות." } } diff --git a/locales/he/pair.json b/locales/he/pair.json index f0dfd45b..e480369a 100644 --- a/locales/he/pair.json +++ b/locales/he/pair.json @@ -4,6 +4,9 @@ "permissionBody": "Threadbase זקוק לגישה למצלמה כדי לסרוק קוד QR לזיווג המוצג על ידי השרת שלך.", "allowCamera": "אפשר מצלמה", "permissionHint": "גישה למצלמה מושבתת. פתח הגדרות כדי לאפשר זאת עבור Threadbase.", + "openSettings": "פתחו הגדרות", + "close": "סגירה", + "contactSupport": "צריכים עזרה? פנו לתמיכה", "exchanging": "מחליף טוקן זיווג…", "errorTitle": "הזיווג נכשל", "tryAgain": "נסה שוב", diff --git a/locales/he/servers.json b/locales/he/servers.json index ca26d92a..1a65706b 100644 --- a/locales/he/servers.json +++ b/locales/he/servers.json @@ -4,39 +4,26 @@ }, "status": { "connected": "מחובר", - "disconnected": "מנותק", - "connecting": "מתחבר…", - "error": "שגיאת חיבור" + "disconnected": "מנותק" }, "action": { "add": "הוסף שרת", - "edit": "ערוך", - "remove": "הסר שרת", - "viewError": "הצג שגיאת חיבור", "connect": "התחבר", "details": "פרטים" }, "dialog": { "removeTitle": "הסר שרת", - "removeMessage": "האם אתה בטוח שברצונך להסיר את השרת הזה?", - "removeConfirm": "הסר", - "removeDismiss": "ביטול" + "removeMessage": "להתנתק מ-{{server}}?", + "removeConfirm": "הסר" }, "form": { - "name": "שם", - "url": "URL", - "namePlaceholder": "השרת שלי", - "urlPlaceholder": "http://localhost:3000", "labelOptional": "תווית (אופציונלי)", "serverUrl": "כתובת שרת", "apiKey": "מפתח API", "hint": "הרץ cch serve --tunnel --qr על ה-Mac שלך כדי לקבל URL הניתן לסריקת QR." }, "manage": { - "title": "שרתים", - "addServer": "הוסף שרת", - "addServerHint": "התחבר למופע tb-streamer", - "empty": "עדיין לא נוספו שרתים." + "addServer": "הוסף שרת" }, "filter": { "title": "מסננים", @@ -69,7 +56,6 @@ "rememberChoice": "זכור את הבחירה הזו" }, "statusModal": { - "title": "סטטוס שרת", "close": "✕", "menuEdit": "ערוך", "menuRefresh": "רענן", @@ -85,5 +71,134 @@ "label": "סורק ומאנדקס שיחות…", "subtitle": "השרת מתחמם ובונה את אינדקס השיחות לראשונה. זה קורה רק פעם אחת — ההיסטוריה תופיע ברגע שתהיה מוכנה.", "progress": "{{scanned}} / {{total}} קבצים" + }, + "cacheAlert": { + "bannerTitle": "{{count}} היסטוריות שיחה חסרות ב-{{server}}", + "title": "{{missing}} מתוך {{total}} היסטוריות שיחה חסרות ב-{{server}}", + "backupHint": "מומלץ לבדוק גיבוי Time Machine (או שווה ערך) לפני ביצוע פעולה הרסנית.", + "actionPruneAll": "מחק הכול", + "actionPruneSelected": "מחק נבחרים", + "actionIgnore": "התעלם", + "actionResetRescan": "אפס וסרוק מחדש", + "confirmTitle": "האם אתה בטוח?", + "confirmPruneAll": "פעולה זו מסירה לצמיתות את כל {{count}} השיחות החסרות מהמטמון. לא ניתן לבטל פעולה זו.", + "confirmPruneSelected": "פעולה זו מסירה לצמיתות {{count}} שיחות נבחרות מהמטמון. לא ניתן לבטל פעולה זו.", + "confirmResetRescan": "פעולה זו מאפסת את המטמון וסורקת מחדש מהדיסק. לא ניתן לבטל פעולה זו.", + "confirmCancel": "ביטול", + "confirmProceed": "המשך", + "selectAll": "בחר הכול", + "selectNone": "נקה בחירה", + "successToast": "טופל. הגיבוי נשמר ב-{{backupPath}}", + "successToastNoBackup": "טופל.", + "selectAtLeastOne": "בחר לפחות שיחה אחת למחיקה." + }, + "claudeFlags": { + "title": "דגלי Claude CLI", + "description": "חלים על כל סשן שהשרת הזה מפעיל. ייכנסו לתוקף בסשן הבא.", + "notPersisted": "השרת הופעל עם דגלי שורת פקודה, ולכן שינויים חלים עכשיו אך יתאפסו באתחול.", + "extraArgsLabel": "ארגומנטים נוספים", + "extraArgsUnsupported": "מועברים כמו שהם וללא אימות. השימוש באחריותך.", + "listHint": "מופרד בפסיקים", + "save": "שמירת דגלים", + "confirmTitle": "לבטל את בקשות ההרשאה?", + "confirmApprove": "אישור", + "confirmContinue": "המשך", + "dangerousExplanation": "קלוד יריץ פקודות במחשב הזה בלי לשאול אותך, בכל סשן עתידי. לכל מי שיש את מפתח ה-API של השרת תהיה אותה יכולת.", + "flags": { + "permissionMode": { + "label": "מצב הרשאות", + "description": "כמה קלוד שואל לפני ביצוע פעולה. מצבי עקיפה מבטלים כל אישור." + }, + "addDir": { + "label": "תיקיות נוספות", + "description": "תיקיות נוספות שקלוד רשאי לקרוא ולערוך, מעבר לפרויקט." + }, + "allowedTools": { + "label": "כלים מורשים", + "description": "רק כלים אלה ירוצו ללא שאלה. חלופה בטוחה יותר למצב עקיפה." + }, + "disallowedTools": { + "label": "כלים חסומים", + "description": "כלים שקלוד לעולם לא יריץ בשרת הזה." + }, + "maxBudgetUsd": { + "label": "מגבלת תקציב (USD)", + "description": "מגבילה את ההוצאה של סשן יחיד. שימושי כשהאישורים מבוטלים." + }, + "fallbackModel": { + "label": "מודל גיבוי", + "description": "המודל שאליו לעבור אם המודל הראשי אינו זמין." + } + } + }, + "health": { + "heading": "בריאות שרת", + "subtitle": "בדיקות עצמאיות מכל סטרימר מחובר. כשלים כוללים קוד ופעולה מומלצת. סודות אינם נכללים.", + "screenTitle": "בריאות שרת", + "emptyTitle": "אין שרתים עדיין", + "emptyBody": "צמד שרת תחילה, ואז חזור לכאן לבדיקת הבריאות.", + "loading": "בודק שרת…", + "loadFailed": "לא ניתן לטעון את בריאות השרת.", + "retry": "נסה שוב", + "overall": "סה״כ", + "generatedAt": "נוצר ב־{{at}}", + "copy": "העתק דוח", + "copied": "הועתק", + "copyFailed": "לא ניתן להעתיק את הדוח.", + "share": "שתף דוח", + "shareFailed": "לא ניתן לשתף את הדוח.", + "unsupportedContract": "הסטרימר מדווח על חוזה אבחון v{{version}}. האפליקציה מצפה ל־v1 — ייתכן שחלק מהשדות חסרים.", + "status": { + "ok": "תקין", + "degraded": "מוחלש", + "failed": "נכשל", + "unknown": "לא ידוע" + }, + "checks": { + "streamer": "סטרימר", + "providerClaude": "Claude Code CLI", + "providerCodex": "Codex CLI", + "cache": "מטמון שיחות", + "pty": "מערכת PTY", + "filesystem": "תיקיית היסטוריית ספק" + }, + "remediation": { + "PROVIDER_NOT_INSTALLED": { + "title": "חסר CLI של הספק", + "action": "במק שמריץ את הסטרימר, התקן את ה־CLI של הספק וודא שהוא ב־PATH, ואז נסה שוב." + }, + "PROVIDER_VERSION_UNVERIFIED": { + "title": "גרסת ספק לא מאומתת", + "action": "עדכן את ה־CLI לגרסה שהסטרימר אימת, ואז נסה שוב." + }, + "DB_UNAVAILABLE": { + "title": "מסד הנתונים אינו זמין", + "action": "הפעל מחדש את הסטרימר. אם זה נמשך, בדוק מקום בדיסק ויומנים במארח." + }, + "DB_MIGRATION_PENDING": { + "title": "מיגרציית מסד ממתינה", + "action": "שדרג והפעל מחדש את הסטרימר כדי להחיל מיגרציות." + }, + "PTY_UNAVAILABLE": { + "title": "PTY אינו זמין", + "action": "התקן/בנה מחדש את הסטרימר כדי שמודול ה־PTY ייטען, ואז הפעל מחדש." + }, + "CACHE_DEGRADED": { + "title": "מטמון השיחות מוחלש", + "action": "פתח שרתים ← טפל בהתראת שלמות המטמון במארח זה, ואז נסה שוב." + }, + "CLOCK_SKEWED": { + "title": "סטיית שעון", + "action": "כוון את שעון המארח (שעה אוטומטית). אסימוני צימוד פגים תוך דקות." + }, + "FS_SCOPE_MISSING": { + "title": "תיקיית היסטוריה חסרה", + "action": "וודא שהספק יצר את תיקיית ההיסטוריה במארח, ואז נסה שוב." + }, + "NONE": { + "title": "אין צורך בפעולה", + "action": "" + } + } } } diff --git a/locales/he/sessions.json b/locales/he/sessions.json index b99e3120..ac6fb322 100644 --- a/locales/he/sessions.json +++ b/locales/he/sessions.json @@ -8,62 +8,32 @@ }, "filter": { "label": "סנן ומיין", - "all": "הכל", - "active": "פעיל", - "waiting": "ממתין", - "completed": "הושלם", - "failed": "נכשל" - }, - "sort": { - "label": "מיון", - "newest": "החדש ביותר", - "oldest": "הישן ביותר", - "az": "א–ת" + "all": "הכל" }, "list": { - "sessionCount_one": "סשן אחד", - "sessionCount_other": "{{count}} סשנים", "empty": "אין סשנים", "emptySubtitle": "התחל סשן Claude Code\nכדי לראות אותו כאן", "noResults": "אין תוצאות", "noResultsSubtitle": "שום דבר לא התאים ל-\"{{query}}\"", - "emptyProjects": "עדיין אין פרויקטים" + "serverEmpty": "אין סשנים בשרת זה", + "serverEmptySubtitle": "התחל סשן כאן או החלף שרת.", + "serverOffline": "השרת לא זמין", + "serverOfflineSubtitle": "מוצגים שרתים אחרים. נסה שוב כשהמארח יחזור.", + "serverWarming": "השרת מתחמם", + "serverWarmingSubtitle": "ההיסטוריה תופיע כשהאינדוקס יסתיים." }, "card": { - "status": { - "active": "פעיל", - "waiting": "ממתין לקלט", - "completed": "הסשן הושלם", - "failed": "הסשן נכשל", - "starting": "מתחיל…", - "stopping": "עוצר…" - }, - "connectedTo": "{{server}}", "copyId": "העתק מזהה סשן", "sendInput": "שלח קלט", "cancel": "בטל סשן", - "remove": "הסר סשן", - "viewError": "הצג שגיאת חיבור", "prompts_one": "פרומפט אחד", "prompts_other": "{{count}} פרומפטים" }, - "takeover": { - "prompt": "לקוח אחר שולט בסשן זה", - "takeControl": "קח שליטה", - "dismiss": "התעלם" - }, - "directory": { - "empty": "תיקייה ריקה", - "loadFailed": "לא ניתן לטעון תיקיות", - "browsingNotConfigured": "גלישה לא מוגדרת" - }, "hub": { "seeAll_one": "ראה את כל השיחה →", "seeAll_other": "ראה את כל {{count}} השיחות →", "msgs_one": "הודעה אחת", - "msgs_other": "{{count}} הודעות", - "prompts_one": "פרומפט אחד", - "prompts_other": "{{count}} פרומפטים" + "msgs_other": "{{count}} הודעות" }, "loading": { "title": "טוען סשנים", @@ -80,12 +50,6 @@ "headerIdle_one": "סרק · {{count}}", "headerIdle_other": "סרק · {{count}}" }, - "rename": { - "title": "שנה שם סשן", - "placeholder": "שם סשן", - "cancel": "ביטול", - "save": "שמירה" - }, "preview": { "liveUnavailable": "תצוגה מקדימה של סשן חי אינה זמינה — פתח את הסשן כדי לראות את הטרמינל החי.", "noMessages": "עדיין אין הודעות.", @@ -112,5 +76,25 @@ "sync": { "cachedData": "מציג נתונים שמורים", "cachedDataSyncing": "מציג נתונים שמורים — מסתנכרן…" + }, + "status": { + "running": "רץ", + "waiting": "ממתין", + "idle": "לא פעיל", + "externalLive": "חיצוני", + "historical": "היסטוריה", + "resumed": "חודש", + "onHold": "בהמתנה", + "completed": "הושלם", + "failed": "נכשל", + "unavailablePath": "לא זמין", + "unavailableWorktree": "אין worktree", + "stale": "לא עדכני", + "livePill": "LIVE", + "externalPill": "EXTERNAL" + }, + "capability": { + "observeOnly": "צפייה בלבד", + "liveControl": "שליטה חיה" } } diff --git a/locales/he/settings.json b/locales/he/settings.json index 3ad661ba..c9d0b3ab 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -21,7 +21,6 @@ "chatViewNote": "הצג סשנים חיים כבועות צ'אט במקום פלט טרמינל גולמי. בטא — ייתכנו באגים והתכונה לא תמיד תעבוד.", "betaBadge": "בטא" }, - "comingSoonBadge": "בקרוב", "permissions": { "camera": "מצלמה", "cameraDesc": "סריקת קוד QR לחיבור עם שרתים", @@ -41,7 +40,6 @@ "theme": "ערכת נושא", "dark": "כהה", "light": "בהיר", - "system": "מערכת", "glassVariations": "וריאציות זכוכית", "layout": "פריסה", "layoutTree": "עץ", @@ -76,7 +74,10 @@ }, "help": { "restartOnboarding": "הפעל מחדש הדרכה", - "helpSupport": "עזרה ותמיכה" + "helpSupport": "עזרה ותמיכה", + "serverHealth": "בריאות שרת", + "pairedDevices": "מכשירים מקושרים", + "backupRestore": "גיבוי ושחזור" }, "crashReporting": { "title": "שיתוף דיווחי קריסה אנונימיים", @@ -86,10 +87,7 @@ "testThrow": "זריקת חריגה לא מטופלת", "testCrashConfirmTitle": "לשלוח קריסת בדיקה?", "testCrashConfirmMessage": "פעולה זו יוצרת שגיאת בדיקה כדי לוודא שהדיווח פועל. זמין רק בגרסאות פיתוח.", - "testCrashSend": "שלח קריסת בדיקה", - "noticeTitle": "דיווח קריסות זמין", - "noticeMessage": "Threadbase יכול כעת לשלוח דיווחי קריסה מנוקים אם תבחר להצטרף. הדיווח נשאר כבוי עד שתפעיל אותו בהגדרות. הנחיות, פלט טרמינל ותוכן סשנים לעולם אינם נכללים.", - "noticeDismiss": "הבנתי" + "testCrashSend": "שלח קריסת בדיקה" }, "servers": { "scanQr": "סרוק QR להוספת שרת" @@ -98,19 +96,10 @@ "title": "שמות סשן", "autoNameFromMessage": "שם אוטומטי מההודעה הראשונה", "aiGeneratedNames": "שמות סשן שנוצרו ב-AI", - "askOnCreateNote": "הצג בקשה לתת שם לסשן כאשר אתה מתחיל חדש.", - "askOnExitNote": "הצע לתת שם לסשן כאשר אתה עוזב אותו, אם עדיין לא נתת לו שם. מושבת כאשר בקשת היצירה כבויה.", "autoNameNote": "הגדר את שם הסשן מהמילים הראשונות של ההודעה הראשונה שלך. בלי AI, בלי טוקנים.", "aiGeneratedNote": "השתמש במודל AI כדי ליצור שם משמעותי. משתמש בטוקנים ממפתח ה-API המוגדר שלך." }, - "quickAccess": { - "title": "גישה מהירה", - "favorites": "מועדפים", - "recentSessions": "סשנים אחרונים", - "popularProjects": "פרויקטים פופולריים" - }, "language": { - "label": "שפה", "english": "English", "hebrew": "עברית", "arabic": "العربية", @@ -140,5 +129,132 @@ "previewLast": "אחרונה", "previewOff": "כבוי" }, - "restartTour": "הפעל מחדש סיור באפליקציה" + "notificationHealth": { + "screenTitle": "בריאות התראות", + "heading": "בריאות התראות", + "subtitle": "מצב רישום push לכל שרת מהסטרימר. אסימונים אינם מוצגים.", + "emptyTitle": "אין שרתים עדיין", + "emptyBody": "צמד שרת תחילה, ואז בדוק כאן את בריאות המשלוח.", + "loading": "בודק בריאות push…", + "loadFailed": "לא ניתן לטעון בריאות push.", + "retry": "נסה שוב", + "reregister": "רשום מחדש", + "reregistering": "רושם…", + "reregistered": "האסימון נרשם מחדש בשרת זה.", + "reregisterFailed": "הרישום מחדש נכשל. בדוק הרשאת התראות ונסה שוב.", + "quietPreview": "שעות שקט (מכשיר זה)", + "quietActive": "שעות השקט פעילות כעת — התראות מושתקות מקומית.", + "quietInactive": "שעות השקט כבויות או לא פעילות כעת.", + "quietWindow": "{{from}} – {{to}}", + "storeAvailable": "מאגר ה־push זמין בשרת זה.", + "storeUnavailable": "מאגר ה־push אינו זמין בשרת זה.", + "noTokens": "אין אסימוני push רשומים לשרת זה עדיין.", + "platform": "Platform", + "registeredAt": "Registered", + "lastSuccess": "Last success", + "lastFailure": "Last failure", + "failureCode": "Failure code", + "failureStreak": "Failure streak", + "state": { + "never-delivered": "Never delivered", + "healthy": "Healthy", + "failing": "Failing", + "dead": "Dead", + "revoked": "Revoked" + }, + "hintNeverDelivered": "Registered, but no successful delivery yet (detection vs delivery).", + "hintDelivery": "Delivery failures are accumulating on the server — re-register or check Expo credentials.", + "hintRevoked": "This token was revoked on the server.", + "hintHealthy": "Recent deliveries succeeded.", + "openRow": "בריאות משלוח", + "reregisterNeedsPermission": "הרשאת התראות כבויה — הפעל בהגדרות המערכת ונסה שוב.", + "reregisterNeedsDevice": "לא ניתן לקבל אסימון push (סימולטורים אינם נרשמים). נסה במכשיר אמיתי." + }, + "pairedDevices": { + "screenTitle": "מכשירים מקושרים", + "heading": "מכשירים מקושרים", + "subtitle": "מכשירים שנרשמו לסטרימר. אישורים לעולם לא מוצגים — רק שמות, יכולות ופעילות.", + "emptyTitle": "אין שרתים עדיין", + "emptyBody": "קשרו שרת תחילה, ואז נהלו את המכשירים כאן.", + "loading": "טוען מכשירים…", + "loadFailed": "לא ניתן לטעון מכשירים מקושרים.", + "retry": "נסה שוב", + "storeUnavailable": "רישום המכשירים אינו זמין בשרת זה.", + "noDevices": "אין מכשירים רשומים עדיין. קישור מהאפליקציה יוצר מכשיר.", + "controlNote": "מכשירים עם שליטת סשן יכולים להפעיל את הסוכן. ביטול מסיר רק את אישור המכשיר הזה — שאר המכשירים נשארים.", + "hasControl": "מכשיר זה יכול לשלוט בסשנים (התחלה, שליחת קלט, הפרעה).", + "thisDevice": "מכשיר זה", + "unnamed": "מכשיר {{id}}", + "noCapabilities": "אין יכולות ברשימה", + "createdAt": "נוצר {{at}}", + "lastSeen": "נראה לאחרונה {{at}}", + "revokedAt": "בוטל {{at}}", + "revokedSection": "מבוטלים", + "revoke": "בטל", + "revokeTitle": "לבטל מכשיר?", + "revokeBody": "לבטל את „{{name}}“? הגישה תאבד בבקשה הבאה.", + "revokeThisBody": "לבטל את אישור המכשיר של הטלפון הזה? מפתח הבעלים המשותף באפליקציה עדיין עובד; רק אסימון המכשיר מבוטל.", + "revokeConfirm": "בטל", + "revoked": "המכשיר בוטל.", + "alreadyRevoked": "המכשיר כבר בוטל.", + "revokeFailed": "לא ניתן לבטל את המכשיר.", + "capability": { + "historyRead": "קריאת היסטוריה", + "sessionControl": "שליטת סשן", + "fsBrowse": "עיון בקבצים", + "fsUpload": "העלאת קבצים", + "notifications": "התראות", + "admin": "ניהול" + } + }, + "backup": { + "screenTitle": "גיבוי ושחזור", + "heading": "גיבוי ושחזור", + "subtitle": "Export and restore Threadbase project metadata for this server.", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then export or restore metadata here.", + "metadataOnly": "הארכיון הוא מטא־דאטה בלבד (זהות פרויקטים ונתיבים). שיחות לא כלולות — הן בהיסטוריית הספק בדיסק וחוזרות בסריקה מחדש.", + "exportSection": "Export", + "restoreSection": "Restore", + "export": "Export now", + "exporting": "Exporting…", + "exported": "Exported {{count}} projects.", + "exportFailed": "Export failed.", + "copy": "Copy JSON", + "share": "Share", + "copied": "Archive copied to clipboard.", + "copyFailed": "Could not copy archive.", + "shareFailed": "Could not share archive.", + "manifestTitle": "Export summary", + "manifestHost": "Host: {{host}}", + "manifestVersion": "Streamer: {{version}}", + "manifestProjects": "Projects: {{count}}", + "manifestCreated": "Created: {{at}}", + "pasteHint": "Paste an archive JSON below, then dry-run before applying.", + "pastePlaceholder": "{ \"manifest\": …, \"projects\": […] }", + "pasteRequired": "Paste an archive JSON first.", + "invalidJson": "Paste is not valid JSON.", + "invalidArchive": "JSON is not a recognized backup archive.", + "pathMapHint": "Optional path rewrite for a machine move (prefix from → to).", + "pathFrom": "From path prefix", + "pathTo": "To path prefix", + "dryRun": "Dry-run restore", + "planning": "Planning…", + "dryRunReady": "Dry-run complete — review the plan, then apply.", + "dryRunFailed": "Dry-run failed.", + "unexpectedApply": "Unexpected apply response from dry-run.", + "apply": "Apply restore", + "applyTitle": "Apply restore?", + "applyBody": "This rewrites project identity on the server. Conversations are not imported from the archive.", + "applyConfirm": "Apply", + "applyFailed": "Apply failed.", + "applied": "Restore applied ({{count}} projects written).", + "conflict": "Restore has path conflicts — resolve them before applying.", + "conflictDetail": "A path is claimed by a different project id. Applying would break one side’s links.", + "conflictRow": "Conflict {{path}} (incoming {{incoming}} vs existing {{existing}})", + "planTitle": "Restore plan", + "planSummary": "Create {{create}} · update {{update}} · conflict {{conflict}}", + "createRow": "Create: {{name}}", + "updateRow": "Update: {{name}}" + } } diff --git a/locales/he/shared.json b/locales/he/shared.json index fe16d9f6..176bdf39 100644 --- a/locales/he/shared.json +++ b/locales/he/shared.json @@ -19,25 +19,6 @@ "pinToFavorites": "נעץ למועדפים", "cancel": "ביטול", "loadMore": "+ {{count}} נוספים", - "emptyFavorites": "עדיין אין מועדפים — לחץ לחיצה ארוכה על פריט כדי לנעוץ אותו.", - "pairServer": "זווג שרת כדי לראות {{tab}}.", - "loading": "טוען…", - "loadFailed": "לא ניתן לטעון — הקש כדי לנסות שוב.", - "nothing": "עדיין כלום.", - "favorites": "מועדפים", - "popular": "פופולרי", - "recents": "אחרונים" - }, - "conversationInfo": { - "title": "מידע שיחה", - "id": "מזהה", - "titleLabel": "כותרת", - "projectPath": "נתיב פרויקט", - "messageCount": "ספירת הודעות", - "lastActivity": "פעילות אחרונה" - }, - "session": { - "active": "פעיל", - "gotIt": "הבנתי" + "emptyFavorites": "עדיין אין מועדפים — לחץ לחיצה ארוכה על פריט כדי לנעוץ אותו." } } diff --git a/locales/he/terminal.json b/locales/he/terminal.json index 36e983e0..1c762dbc 100644 --- a/locales/he/terminal.json +++ b/locales/he/terminal.json @@ -1,7 +1,4 @@ { - "header": { - "session": "סשן" - }, "connection": { "reconnectingTitle": "מתחבר מחדש…", "reconnectingMessage": "החיבור אבד — התוכן שלמטה עשוי להיות לא מעודכן.", @@ -9,38 +6,24 @@ "stalledMessage": "מחובר, אך לא התקבל פלט כבר זמן מה." }, "action": { - "copyId": "העתק מזהה סשן", "sendInput": "שלח קלט", - "cancel": "בטל סשן", - "more": "עוד", "stop": "עצור סשן" }, "voice": { "start": "התחל הכתבה", - "stop": "עצור הכתבה", - "permissionDeniedTitle": "הרשאת מיקרופון נדחתה", - "permissionDeniedBody": "Threadbase זקוק לגישה למיקרופון כדי להכתיב פרומפטים. אפשר זאת בהגדרות." + "stop": "עצור הכתבה" }, "dialog": { "cancelTitle": "בטל סשן", "cancelMessage": "האם אתה בטוח שברצונך לבטל את הסשן הזה?", "cancelConfirm": "בטל סשן", - "cancelDismiss": "המשך ריצה", "removeTitle": "הסר סשן", - "removeMessage": "האם אתה בטוח שברצונך להסיר את הסשן הזה?", "removeConfirm": "הסר", - "removeDismiss": "שמור", "stopTitle": "עצור סשן", - "stopMessage": "לעצור את הסשן הזה? קלוד יופסק.", - "stopConfirm": "עצור", - "stopDismiss": "המשך ריצה", "stopFailed": "לא ניתן לעצור את הסשן. בדוק את החיבור ונסה שוב." }, "status": { - "connecting": "מתחבר…", - "reconnecting": "מתחבר מחדש…", "disconnected": "מנותק", - "waking": "מתעורר…", "starting": "מתחיל סשן…", "stuckTitle": "עדיין מתחיל…", "stuckBody": "הסשן עדיין לא הגיע למצב מוכן. אפשר לראות את פלט הקונסולה שלו בזמן אמת במקום להמשיך לחכות.", @@ -48,11 +31,12 @@ "waitMore": "המתן עוד" }, "input": { - "placeholder": "שלח הודעה…", - "send": "שלח" + "placeholder": "שלח הודעה…" }, "session": { "notFound": "סשן לא נמצא", + "notFoundBody": "הסשן כבר לא קיים בשרת. ייתכן שהוא הסתיים או נוקה.", + "backToHub": "חזרה למרכז", "ended": "ההפעלה הסתיימה", "endedBody": "ההפעלה הסתיימה מבלי שהופק פלט כלשהו.", "failedToStart": "הפעלת הסשן נכשלה", @@ -66,10 +50,13 @@ "overtake": "השתלט", "prompts_one": "פרומפט אחד", "prompts_other": "{{count}} פרומפטים", - "tabTerminal": "טרמינל", - "tabChat": "צ'אט" - }, - "banner": { - "streamingInfo": "קריאות כלי ופלט זורמים כאן בזמן אמת. הקלד המשך למטה בזמן ש-Claude עדיין עובד." + "viewModeTerminal": "טרמינל", + "viewModeChat": "צ׳אט", + "rawFallbackBanner": "מוצג טרמינל גולמי — נרמול הצ׳אט אינו ודאי עבור הזרם הזה.", + "ptyActiveFallbackBanner": "פלט טרמינל חי פעיל לפני שזמינים הודעות שיחה.", + "rawModeNote": "מצב טרמינל גולמי — זוהו רצפים שאינם נתמכים; הפלט אינו מסונן." + }, + "a11y": { + "line": "שורה {{n}}: {{text}}" } } diff --git a/locales/ru/browse.json b/locales/ru/browse.json index de1aeeb5..a305d12c 100644 --- a/locales/ru/browse.json +++ b/locales/ru/browse.json @@ -2,6 +2,10 @@ "nav": { "back": "‹ Назад", "recentDirs": "Недавние директории ({{count}})", + "displayAll": "Показать все", + "allRecentDirs": "Недавние директории", + "searchRecentDirs": "Поиск расположений", + "noMatchingDirs": "Нет подходящих расположений", "newFolder": "Новая папка", "create": "Создать", "startSession": "Начать сессию здесь" @@ -22,5 +26,15 @@ "retryHint": "Потяните вниз для повтора", "startFailed": "Не удалось запустить сессию", "startTimeout": "Сервер не ответил вовремя. Сессия может всё ещё запускаться — проверьте список сессий перед повторной попыткой." + }, + "provider": { + "unavailable": "CLI провайдера не установлен на сервере. Выберите другого или установите на хосте.", + "noStructuredQuestions": "У этого провайдера нет структурированных меню вопросов — отвечайте в сыром терминале.", + "observeOnly": "Живое управление недоступно; сессии только для наблюдения.", + "warning": { + "provider_not_found": "CLI провайдера не найден в PATH сервера.", + "version_undetectable": "Не удалось прочитать версию провайдера — совместимость не проверена.", + "version_unverified": "Версия провайдера вне диапазона, проверенного стримером." + } } } diff --git a/locales/ru/common.json b/locales/ru/common.json index bf808b1e..7b8ea4e1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3,41 +3,21 @@ "cancel": "Отмена", "confirm": "Подтвердить", "save": "Сохранить", - "done": "Готово", "back": "Назад", "close": "Закрыть", "retry": "Повторить", - "remove": "Удалить", - "apply": "Применить", - "skip": "Пропустить", - "create": "Создать", - "export": "Экспорт", - "copy": "Копировать", - "paste": "Вставить", - "openSettings": "Открыть настройки" + "apply": "Применить" }, "error": { - "generic": "Что-то пошло не так", - "loadFailed": "Не удалось загрузить", - "connectionFailed": "Ошибка подключения" - }, - "state": { - "loading": "Загрузка…", - "empty": "Пока ничего нет", - "noResults": "Нет результатов" + "loadFailed": "Не удалось загрузить" }, "search": { - "placeholder": "Поиск…", - "clear": "Очистить поиск" + "placeholder": "Поиск…" }, "nav": { "top": "↑ Вверх", "bottom": "↓ Вниз", - "backArrow": "‹ Назад" - }, - "time": { - "now": "сейчас", - "yesterday": "Вчера" + "copyAll": "Копировать всё" }, "biometricLock": { "title": "Threadbase заблокирован", @@ -57,5 +37,9 @@ "enable": "Включить", "notNow": "Не сейчас" } + }, + "renderError": { + "title": "Не удалось отобразить элемент", + "message": "Показан сырой запасной вариант вместо сбоя экрана." } } diff --git a/locales/ru/conversation.json b/locales/ru/conversation.json index a843e21c..f9fa87bf 100644 --- a/locales/ru/conversation.json +++ b/locales/ru/conversation.json @@ -5,16 +5,12 @@ }, "list": { "loadingMore": "Загрузка…", - "empty": "В этой беседе нет сообщений.", - "msgs_one": "{{count}} сообщение", - "msgs_few": "{{count}} сообщения", - "msgs_many": "{{count}} сообщений", - "msgs_other": "{{count}} сообщений", - "tokens": "{{count}}k токенов" + "empty": "В этой беседе нет сообщений." }, "error": { "loadFailed": "Не удалось загрузить беседу", "notFound": "Эта беседа больше недоступна на сервере.", + "back": "Назад в хаб", "resumeTimeout": "Сервер не ответил вовремя. Сессия может всё ещё запускаться — проверьте список сессий перед повторной попыткой." }, "unavailable": { @@ -22,6 +18,27 @@ "worktreeRemoved": "Эта беседа выполнялась в git worktree, которого больше нет. Историю можно просмотреть, но возобновить нельзя.", "cannotResume": "Нельзя возобновить" }, + "resume": { + "resuming": "Возобновление…", + "collisionTitle": "Возобновить этот разговор?", + "collisionMessage": "Возможно, этот разговор всё ещё открыт в терминале на вашем компьютере — {{reasons}}. Возобновление здесь может помешать той сессии.", + "confirm": "Всё равно возобновить", + "failed": "Не удалось возобновить разговор. Попробуйте ещё раз.", + "reason": { + "jsonl_mtime": "её история обновлялась только что", + "process_argv": "возможно, соответствующий процесс терминала ещё работает", + "process_cwd": "возможно, процесс всё ещё использует папку её проекта", + "unknown": "возможно, другая программа всё ещё использует её" + }, + "takeOver": "Перехватить", + "takeOverFailed": "Не удалось перехватить эту сессию. Попробуйте ещё раз." + }, + "live": { + "indicator": "В эфире", + "paused": "Пауза", + "pause": "Приостановить живые обновления", + "resume": "Возобновить живые обновления" + }, "action": { "export": "Экспорт", "copyPatch": "Копировать патч", @@ -43,12 +60,36 @@ "matchCount": "{{current}} из {{total}}", "previousMatch": "Предыдущий результат", "nextMatch": "Следующий результат", - "clearSearch": "Очистить поиск" + "clearSearch": "Очистить поиск", + "placeholder": "Поиск в разговоре…", + "open": "Поиск в разговоре" }, "slowLoading": { "title1": "Распутываем длинную ветку…", "title2": "Сообщения элегантно опаздывают…", "title3": "Сообщения застряли в пробке…", "message": "Не ожидали, что они окажутся такими тяжёлыми. Загружаем изо всех сил." + }, + "review": { + "title": "Обзор изменений", + "open": "Открыть обзор", + "summary": "{{files}} файлов · +{{added}} / −{{removed}}", + "incompleteWarning": "Собрано из инструментов Edit/Write беседы — может быть неполным относительно git status.", + "oversizedWarning": "Некоторые diff обрезаны ради производительности. Скопируйте пакет для обзора на компьютере.", + "filterAll": "Все", + "kindEdited": "Изменено", + "kindWritten": "Записано", + "kindDiff": "Diff", + "kindUnknown": "Другое", + "searchPlaceholder": "Фильтр файлов…", + "empty": "В этой беседе пока нет изменений файлов.", + "selectFile": "Выберите файл, чтобы увидеть diff.", + "copyHandoff": "Копировать для компьютера", + "sendNote": "Отправить заметку агенту", + "sendNoteTitle": "Отправить заметку обзора?", + "sendNoteBody": "Отправит краткий список файлов во вход живой сессии.", + "cancel": "Отмена", + "send": "Отправить", + "fileMeta": "{{kind}} · +{{added}} / −{{removed}}" } } diff --git a/locales/ru/feedback.json b/locales/ru/feedback.json index 7cb5fc54..e7a9d378 100644 --- a/locales/ru/feedback.json +++ b/locales/ru/feedback.json @@ -63,16 +63,12 @@ "success": { "title": "Отзыв закоммичен", "message": "Спасибо — ваш отчёт попал в очередь без конфликтов слияния.", + "viaSentry": "Отправлено через службу отчётов о сбоях Sentry", + "viaEmail": "Отправлено по email", "done": "Готово" }, "error": { - "title": "Не удалось отправить", - "message": "Мы не смогли отправить ваш отчёт. Ваш отзыв всё ещё здесь, так что можно попробовать снова.", - "tryAgain": "Попробовать снова" - }, - "offline": { - "title": "Похоже, сетевая ветка офлайн", - "message": "Переподключитесь и попробуйте снова или скопируйте отчёт на потом." + "message": "Мы не смогли отправить ваш отчёт. Ваш отзыв всё ещё здесь, так что можно попробовать снова." }, "screenshotError": { "title": "Этот скриншот не помещается", diff --git a/locales/ru/onboarding.json b/locales/ru/onboarding.json index 4d75488b..baf26d91 100644 --- a/locales/ru/onboarding.json +++ b/locales/ru/onboarding.json @@ -1,69 +1,47 @@ { "welcome": { - "title": "Добро пожаловать в Threadbase", - "subtitle": "Подключитесь к вашим сессиям Claude Code", "eyebrow": "// AMBIENT CODING", "headline": "Потяните нить.", "headlineAccent": "Смотрите, как она плетётся.", "body": "Пульт дистанционного управления для Claude Code на устройстве, которое вы носите с собой.", "cta": "Начать" }, - "addServer": { - "title": "Добавить сервер", - "subtitle": "Введите URL вашего сервера Threadbase для начала" - }, - "browse": { - "title": "Обзор", - "empty": "Нет файлов", - "emptySubtitle": "Эта директория пуста" - }, - "conversation": { - "title": "Беседа", - "empty": "Нет сообщений", - "emptySubtitle": "История бесед пока пуста" - }, "shell": { "back": "Назад", - "skip": "Пропустить" - }, - "serverName": { - "eyebrow": "> 02 / ИМЯ", - "headline": "Назовите ваш сервер.", - "body": "Необязательно. Выберите метку, которую вы узнаете в центре — например \"Рабочий Mac\" или \"Домашний сервер\". Вы можете изменить её позже в настройках.", - "fieldLabel": "Имя сервера", - "placeholder": "напр. Рабочий Mac", - "cta": "Продолжить", - "skip": "Пропустить пока" + "skip": "Пропустить", + "pairLater": "Подключить позже" }, "connect": { "eyebrow": "> 02 / СОПРЯЖЕНИЕ", "headline": "Подключить среду выполнения.", "modeBlurb": "Выберите, как передать серверу его ключи.", + "connectivityHint": "Телефон достучится до компьютера по той же Wi‑Fi, через VPN (например Tailscale) или публичный туннельный URL.", + "recommended": "Рекомендуется", + "invalidUrl": "Введите полный адрес сервера с http:// или https://.", + "contactSupport": "Нужна помощь? Свяжитесь с поддержкой", "scanQr": "Сканировать QR", "scanQrBody": "Запустите tb pair на вашем сервере, чтобы вывести QR. Самый быстрый способ, без ввода.", "pasteCredentials": "Ввести / вставить вручную", - "pasteCredentialsBody": "Запустите tb pair на вашем сервере, затем вставьте URL и токен.", + "pasteCredentialsBody": "Запустите tb pair на вашем сервере, затем вставьте URL + токен — или полную ссылку threadbase://.", "qrEyebrow": "> 02 / СОПРЯЖЕНИЕ · QR", "qrHeadline": "Сканируйте для сопряжения.", - "step1": "1. На вашем сервере запустите tb pair. QR выведется в терминал.", - "step2": "2. Нажмите Открыть камеру ниже. Threadbase запросит разрешение на использование камеры — она используется только для чтения QR.", "openCamera": "Открыть камеру", - "pairMyLaptop": "Сопрячь мой ноутбук", "ready": "✓ готово", - "footnote": "// На вашем компьютере запустите tb pair, чтобы вывести токен.", - "manualSectionLabel": "На вашем Mac", + "manualSectionLabel": "На вашем компьютере", "manualSectionHint": "Откройте Терминал и запустите:", - "manualSectionPasteHint": "Он выведет URL + токен — вставьте оба поля ниже.", - "manualServerUrl": "URL сервера", + "manualSectionPasteHint": "Он выведет URL + токен (или ссылку threadbase://) — вставьте ниже.", "manualToken": "Токен", - "manualTokenPlaceholder": "вставьте токен из вывода tb pair", "manualScanInstead": "Сканировать QR вместо этого →", "manualEnterInstead": "Ввести вручную вместо этого", "connectIdle": "Подключить", "connectOk": "Подключено", "connectErr": "Повторить", "connectBusy": "…подключение", - "connectErrHint": "Токен истёк? Запустите tb pair снова на вашем Mac." + "connectErrHint": "Токен истёк? Снова выполните tb pair на компьютере.", + "backToOptions": "← Другие варианты", + "step1": "1. На вашем сервере запустите tb pair. QR выведется в терминал.", + "step2": "2. Нажмите Открыть камеру ниже. Threadbase запросит разрешение на использование камеры — она используется только для чтения QR.", + "step3": "3. Наведите телефон на QR. Токен действует 3 минуты; если истёк — снова выполните tb pair." }, "done": { "eyebrowPaired": "РУКОПОЖАТИЕ ЗАВЕРШЕНО", @@ -72,30 +50,12 @@ "headlineUnpaired": "Вы внутри.", "bodyPaired": "Ваш ноутбук слушает. Открывайте сессию, когда захотите.", "bodyUnpaired": "Пропустите пока — вы можете подключить среду выполнения из Настроек в любое время.", + "pillPaired": "подключено · {{host}} · {{port}}", + "pillPairedNamed": "подключено · {{name}} · {{host}} · {{port}}", "pillUnpaired": "среда не подключена · подключить из Настроек", "ctaPaired": "Войти в Threadbase", "ctaUnpaired": "Продолжить — подключу позже" }, - "tour": { - "eyebrow": "> 04 / ОБЗОР", - "eyebrowProgress": "> 04 / ОБЗОР · {{current}}/{{total}}", - "dropIn": "Погрузиться", - "nextConcept": "Следующая концепция" - }, - "valueProp": { - "eyebrow": "// ВНЕШНИЙ ВИД", - "eyebrowWhy": "> 01 / ЗАЧЕМ", - "headline": "Ваш ноутбук — среда выполнения. Ваш телефон — кабина.", - "headlineMain": "Ваш ноутбук — среда выполнения.", - "headlineMuted": "Ваш телефон — кабина.", - "headlineAccent": "вайб.", - "body": "Threadbase поддерживает ваших агентов живыми на устройстве с GPU, ключами и файловым деревом — и даёт вам спокойную поверхность для управления ими откуда угодно.", - "cta": "Сопрячь мой ноутбук", - "labelYou": "ВЫ", - "labelPrompts": "промпты ▸", - "labelStdout": "◂ stdout", - "labelClaudeRun": "claude run" - }, "notifications": { "allowTitle": "Push-уведомления", "body": "Threadbase уведомит вас, когда сессии нужно решение, она завершит запуск или столкнётся с ошибкой — то, что стоит знать без наблюдения за терминалом.", @@ -103,10 +63,5 @@ "skipCta": "Пропустить — я буду смотреть канбан", "enabledStatus": "ВКЛЮЧЕНО · alerts.threadbase.dev", "tapToAllow": "НАЖМИТЕ ДЛЯ РАЗРЕШЕНИЯ" - }, - "theme": { - "eyebrow": "// ВНЕШНИЙ ВИД", - "headlineLine1": "Выберите вашу", - "body": "Выберите цветовую тему. Вы всегда можете изменить её позже в настройках." } } diff --git a/locales/ru/pair.json b/locales/ru/pair.json index a0bd7cb2..2b8e4bc8 100644 --- a/locales/ru/pair.json +++ b/locales/ru/pair.json @@ -4,6 +4,9 @@ "permissionBody": "Threadbase требуется доступ к камере для сканирования QR-кода сопряжения, показанного вашим сервером.", "allowCamera": "Разрешить камеру", "permissionHint": "Доступ к камере отключён. Откройте настройки, чтобы включить его для Threadbase.", + "openSettings": "Открыть настройки", + "close": "Закрыть", + "contactSupport": "Нужна помощь? Свяжитесь с поддержкой", "exchanging": "Обмен токеном сопряжения…", "errorTitle": "Сопряжение не удалось", "tryAgain": "Попробовать снова", diff --git a/locales/ru/servers.json b/locales/ru/servers.json index 8bc3c597..ece3067d 100644 --- a/locales/ru/servers.json +++ b/locales/ru/servers.json @@ -4,39 +4,26 @@ }, "status": { "connected": "Подключено", - "disconnected": "Отключено", - "connecting": "Подключение…", - "error": "Ошибка подключения" + "disconnected": "Отключено" }, "action": { "add": "Добавить сервер", - "edit": "Изменить", - "remove": "Удалить сервер", - "viewError": "Показать ошибку подключения", "connect": "Подключить", "details": "Подробнее" }, "dialog": { "removeTitle": "Удалить сервер", - "removeMessage": "Вы уверены, что хотите удалить этот сервер?", - "removeConfirm": "Удалить", - "removeDismiss": "Отмена" + "removeMessage": "Отключиться от {{server}}?", + "removeConfirm": "Удалить" }, "form": { - "name": "Имя", - "url": "URL", - "namePlaceholder": "Мой сервер", - "urlPlaceholder": "http://localhost:3000", "labelOptional": "Метка (необязательно)", "serverUrl": "URL сервера", "apiKey": "API-ключ", "hint": "Запустите cch serve --tunnel --qr на вашем Mac, чтобы получить URL с QR-кодом." }, "manage": { - "title": "Серверы", - "addServer": "Добавить сервер", - "addServerHint": "Подключитесь к экземпляру tb-streamer", - "empty": "Серверы ещё не добавлены." + "addServer": "Добавить сервер" }, "filter": { "title": "Фильтры", @@ -69,7 +56,6 @@ "rememberChoice": "Запомнить этот выбор" }, "statusModal": { - "title": "Статус сервера", "close": "✕", "menuEdit": "Изменить", "menuRefresh": "Обновить", @@ -85,5 +71,134 @@ "label": "Сканирование и индексирование бесед…", "subtitle": "Сервер прогревается и впервые строит индекс бесед. Это происходит только один раз — история появится, как только будет готова.", "progress": "{{scanned}} / {{total}} файлов" + }, + "cacheAlert": { + "bannerTitle": "{{count}} истории бесед отсутствуют на {{server}}", + "title": "{{missing}} из {{total}} историй бесед отсутствуют на {{server}}", + "backupHint": "Перед выполнением необратимых действий рекомендуется проверить резервную копию Time Machine (или аналог).", + "actionPruneAll": "Удалить все", + "actionPruneSelected": "Удалить выбранные", + "actionIgnore": "Игнорировать", + "actionResetRescan": "Сбросить и пересканировать", + "confirmTitle": "Вы уверены?", + "confirmPruneAll": "Это навсегда удалит все {{count}} отсутствующих бесед из кэша. Это действие нельзя отменить.", + "confirmPruneSelected": "Это навсегда удалит {{count}} выбранных бесед из кэша. Это действие нельзя отменить.", + "confirmResetRescan": "Это сбросит кэш и пересканирует диск. Это действие нельзя отменить.", + "confirmCancel": "Отмена", + "confirmProceed": "Продолжить", + "selectAll": "Выбрать все", + "selectNone": "Снять выбор", + "successToast": "Готово. Резервная копия сохранена в {{backupPath}}", + "successToastNoBackup": "Готово.", + "selectAtLeastOne": "Выберите хотя бы одну беседу для удаления." + }, + "claudeFlags": { + "title": "Флаги Claude CLI", + "description": "Применяются к каждой сессии этого сервера. Вступают в силу со следующей сессии.", + "notPersisted": "Сервер запущен с флагами командной строки: изменения действуют сейчас, но сбросятся при перезапуске.", + "extraArgsLabel": "Дополнительные аргументы", + "extraArgsUnsupported": "Передаются как есть, без проверки. На ваш риск.", + "listHint": "Через запятую", + "save": "Сохранить флаги", + "confirmTitle": "Отключить запросы разрешений?", + "confirmApprove": "Подтвердить", + "confirmContinue": "Продолжить", + "dangerousExplanation": "Claude будет выполнять команды на этой машине без вопросов во всех будущих сессиях. Любой, у кого есть API-ключ сервера, получит те же возможности.", + "flags": { + "permissionMode": { + "label": "Режим разрешений", + "description": "Насколько Claude спрашивает перед действием. Режимы обхода убирают все подтверждения." + }, + "addDir": { + "label": "Дополнительные папки", + "description": "Папки вне проекта, которые Claude может читать и изменять." + }, + "allowedTools": { + "label": "Разрешённые инструменты", + "description": "Только они выполняются без вопросов. Безопаснее режима обхода." + }, + "disallowedTools": { + "label": "Заблокированные инструменты", + "description": "Инструменты, которые Claude никогда не выполнит на этом сервере." + }, + "maxBudgetUsd": { + "label": "Лимит бюджета (USD)", + "description": "Ограничивает траты одной сессии. Полезно при отключённых подтверждениях." + }, + "fallbackModel": { + "label": "Запасная модель", + "description": "Модель на случай недоступности основной." + } + } + }, + "health": { + "heading": "Состояние сервера", + "subtitle": "Независимые проверки с каждого подключённого стримера. Сбои содержат код и рекомендуемое действие. Секреты не включаются.", + "screenTitle": "Состояние сервера", + "emptyTitle": "Серверов пока нет", + "emptyBody": "Сначала привяжите сервер, затем вернитесь сюда для проверки.", + "loading": "Проверка сервера…", + "loadFailed": "Не удалось загрузить состояние сервера.", + "retry": "Повторить", + "overall": "Итого", + "generatedAt": "Создано {{at}}", + "copy": "Копировать отчёт", + "copied": "Скопировано", + "copyFailed": "Не удалось скопировать отчёт.", + "share": "Поделиться отчётом", + "shareFailed": "Не удалось поделиться отчётом.", + "unsupportedContract": "Стример сообщает контракт диагностики v{{version}}. Приложение ожидает v1 — часть полей может отсутствовать.", + "status": { + "ok": "В норме", + "degraded": "Ограничено", + "failed": "Сбой", + "unknown": "Неизвестно" + }, + "checks": { + "streamer": "Стример", + "providerClaude": "Claude Code CLI", + "providerCodex": "Codex CLI", + "cache": "Кэш бесед", + "pty": "Подсистема PTY", + "filesystem": "Папка истории провайдера" + }, + "remediation": { + "PROVIDER_NOT_INSTALLED": { + "title": "CLI провайдера не установлен", + "action": "На Mac со стримером установите CLI провайдера и убедитесь, что он в PATH, затем повторите." + }, + "PROVIDER_VERSION_UNVERIFIED": { + "title": "Версия провайдера не проверена", + "action": "Обновите CLI до версии, которую стример проверил, затем повторите." + }, + "DB_UNAVAILABLE": { + "title": "База данных недоступна", + "action": "Перезапустите стример. Если не помогает — проверьте диск и логи на хосте." + }, + "DB_MIGRATION_PENDING": { + "title": "Ожидает миграция БД", + "action": "Обновите и перезапустите стример, чтобы применить миграции." + }, + "PTY_UNAVAILABLE": { + "title": "PTY недоступен", + "action": "Переустановите/пересоберите стример, чтобы загрузился модуль PTY, затем перезапустите." + }, + "CACHE_DEGRADED": { + "title": "Кэш бесед повреждён", + "action": "Откройте Серверы → устраните предупреждение целостности кэша на этом хосте, затем повторите." + }, + "CLOCK_SKEWED": { + "title": "Сдвиг часов", + "action": "Настройте часы хоста (автовремя). Токены сопряжения истекают за минуты." + }, + "FS_SCOPE_MISSING": { + "title": "Нет папки истории", + "action": "Убедитесь, что провайдер создал папку истории на хосте, затем повторите." + }, + "NONE": { + "title": "Действий не требуется", + "action": "" + } + } } } diff --git a/locales/ru/sessions.json b/locales/ru/sessions.json index 27c90744..65f90671 100644 --- a/locales/ru/sessions.json +++ b/locales/ru/sessions.json @@ -8,59 +8,29 @@ }, "filter": { "label": "Фильтр и сортировка", - "all": "Все", - "active": "Активные", - "waiting": "Ожидание", - "completed": "Завершённые", - "failed": "С ошибками" - }, - "sort": { - "label": "Сортировка", - "newest": "Новые", - "oldest": "Старые", - "az": "А–Я" + "all": "Все" }, "list": { - "sessionCount_one": "{{count}} сессия", - "sessionCount_few": "{{count}} сессии", - "sessionCount_many": "{{count}} сессий", - "sessionCount_other": "{{count}} сессий", "empty": "Нет сессий", "emptySubtitle": "Запустите сессию Claude Code,\nчтобы увидеть её здесь", "noResults": "Ничего не найдено", "noResultsSubtitle": "Ничего не соответствует \"{{query}}\"", - "emptyProjects": "Проектов пока нет" + "serverEmpty": "На этом сервере нет сессий", + "serverEmptySubtitle": "Запустите сессию здесь или переключите сервер.", + "serverOffline": "Сервер недоступен", + "serverOfflineSubtitle": "Показаны другие серверы. Повторите попытку, когда хост вернётся.", + "serverWarming": "Сервер прогревается", + "serverWarmingSubtitle": "История появится после завершения индексации." }, "card": { - "status": { - "active": "Активна", - "waiting": "Ожидание ввода", - "completed": "Сессия завершена", - "failed": "Ошибка сессии", - "starting": "Запуск…", - "stopping": "Остановка…" - }, - "connectedTo": "{{server}}", "copyId": "Копировать ID сессии", "sendInput": "Отправить ввод", "cancel": "Отменить сессию", - "remove": "Удалить сессию", - "viewError": "Показать ошибку подключения", "prompts_one": "{{count}} промпт", "prompts_few": "{{count}} промпта", "prompts_many": "{{count}} промптов", "prompts_other": "{{count}} промптов" }, - "takeover": { - "prompt": "Другой клиент управляет этой сессией", - "takeControl": "Взять управление", - "dismiss": "Закрыть" - }, - "directory": { - "empty": "Пустая директория", - "loadFailed": "Не удалось загрузить директории", - "browsingNotConfigured": "Просмотр не настроен" - }, "hub": { "seeAll_one": "Показать {{count}} беседу →", "seeAll_few": "Показать {{count}} беседы →", @@ -69,11 +39,7 @@ "msgs_one": "{{count}} сообщение", "msgs_few": "{{count}} сообщения", "msgs_many": "{{count}} сообщений", - "msgs_other": "{{count}} сообщений", - "prompts_one": "{{count}} промпт", - "prompts_few": "{{count}} промпта", - "prompts_many": "{{count}} промптов", - "prompts_other": "{{count}} промптов" + "msgs_other": "{{count}} сообщений" }, "loading": { "title": "Загрузка сессий", @@ -94,12 +60,6 @@ "headerIdle_many": "БЕЗ АКТИВНОСТИ · {{count}}", "headerIdle_other": "БЕЗ АКТИВНОСТИ · {{count}}" }, - "rename": { - "title": "Переименовать сессию", - "placeholder": "Название сессии", - "cancel": "Отмена", - "save": "Сохранить" - }, "preview": { "liveUnavailable": "Предпросмотр активной сессии недоступен — откройте сессию, чтобы увидеть терминал в реальном времени.", "noMessages": "Сообщений пока нет.", @@ -126,5 +86,25 @@ "sync": { "cachedData": "Показаны кэшированные данные", "cachedDataSyncing": "Показаны кэшированные данные — синхронизация…" + }, + "status": { + "running": "Работает", + "waiting": "Ожидание", + "idle": "Простой", + "externalLive": "Внешняя", + "historical": "История", + "resumed": "Возобновлена", + "onHold": "На паузе", + "completed": "Завершена", + "failed": "Ошибка", + "unavailablePath": "Недоступна", + "unavailableWorktree": "Нет worktree", + "stale": "Устарела", + "livePill": "LIVE", + "externalPill": "EXTERNAL" + }, + "capability": { + "observeOnly": "Только просмотр", + "liveControl": "Управление" } } diff --git a/locales/ru/settings.json b/locales/ru/settings.json index 33836190..09e4d8df 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -21,7 +21,6 @@ "chatViewNote": "Показывать активные сессии в виде чат-пузырей вместо необработанного вывода терминала. Бета — может работать нестабильно.", "betaBadge": "Бета" }, - "comingSoonBadge": "Скоро", "permissions": { "camera": "Камера", "cameraDesc": "Сканирование QR-кода для подключения к серверам", @@ -41,7 +40,6 @@ "theme": "Тема", "dark": "Тёмная", "light": "Светлая", - "system": "Системная", "glassVariations": "Варианты стекла", "layout": "Макет", "layoutTree": "Дерево", @@ -76,7 +74,10 @@ }, "help": { "restartOnboarding": "Перезапустить руководство", - "helpSupport": "Помощь и поддержка" + "helpSupport": "Помощь и поддержка", + "serverHealth": "Состояние сервера", + "pairedDevices": "Привязанные устройства", + "backupRestore": "Резервная копия" }, "crashReporting": { "title": "Отправлять анонимные отчёты о сбоях", @@ -86,10 +87,7 @@ "testThrow": "Вызвать необработанное исключение", "testCrashConfirmTitle": "Отправить тестовый сбой?", "testCrashConfirmMessage": "Это вызовет тестовую ошибку, чтобы убедиться, что отчёты работают. Доступно только в сборках для разработки.", - "testCrashSend": "Отправить тестовый сбой", - "noticeTitle": "Отчёты о сбоях доступны", - "noticeMessage": "Threadbase теперь может отправлять очищенные отчёты о сбоях, если вы согласитесь. Функция остаётся выключенной, пока вы не включите её в настройках. Запросы, вывод терминала и содержимое сессий никогда не включаются.", - "noticeDismiss": "Понятно" + "testCrashSend": "Отправить тестовый сбой" }, "servers": { "scanQr": "Сканировать QR для добавления сервера" @@ -98,19 +96,10 @@ "title": "Именование сессий", "autoNameFromMessage": "Автоназвание из первого сообщения", "aiGeneratedNames": "Названия сессий, созданные AI", - "askOnCreateNote": "Показать подсказку для именования сессии при создании новой.", - "askOnExitNote": "Предложить именовать сессию при выходе, если она ещё не была названа. Подавляется, когда подсказка создания отключена.", "autoNameNote": "Установить имя сессии из первых слов вашего первого сообщения. Без AI, без токенов.", "aiGeneratedNote": "Использовать AI-модель для создания осмысленного имени. Использует токены из вашего настроенного API-ключа." }, - "quickAccess": { - "title": "Быстрый доступ", - "favorites": "Избранное", - "recentSessions": "Недавние сессии", - "popularProjects": "Популярные проекты" - }, "language": { - "label": "Язык", "english": "English", "hebrew": "עברית", "arabic": "العربية", @@ -140,5 +129,132 @@ "previewLast": "Последнее", "previewOff": "Выкл." }, - "restartTour": "Перезапустить обзор приложения" + "notificationHealth": { + "screenTitle": "Состояние уведомлений", + "heading": "Состояние уведомлений", + "subtitle": "Состояние push-регистрации по серверам со стримера. Токены не показываются.", + "emptyTitle": "Серверов пока нет", + "emptyBody": "Сначала привяжите сервер, затем проверьте доставку здесь.", + "loading": "Проверка push…", + "loadFailed": "Не удалось загрузить состояние push.", + "retry": "Повторить", + "reregister": "Перерегистрировать", + "reregistering": "Регистрация…", + "reregistered": "Токен снова зарегистрирован на этом сервере.", + "reregisterFailed": "Перерегистрация не удалась. Проверьте разрешение на уведомления.", + "quietPreview": "Тихие часы (это устройство)", + "quietActive": "Тихие часы активны — локальные оповещения подавляются.", + "quietInactive": "Тихие часы выключены или сейчас не активны.", + "quietWindow": "{{from}} – {{to}}", + "storeAvailable": "Хранилище push доступно на этом сервере.", + "storeUnavailable": "Хранилище push недоступно на этом сервере.", + "noTokens": "Для этого сервера ещё нет зарегистрированных push-токенов.", + "platform": "Platform", + "registeredAt": "Registered", + "lastSuccess": "Last success", + "lastFailure": "Last failure", + "failureCode": "Failure code", + "failureStreak": "Failure streak", + "state": { + "never-delivered": "Never delivered", + "healthy": "Healthy", + "failing": "Failing", + "dead": "Dead", + "revoked": "Revoked" + }, + "hintNeverDelivered": "Registered, but no successful delivery yet (detection vs delivery).", + "hintDelivery": "Delivery failures are accumulating on the server — re-register or check Expo credentials.", + "hintRevoked": "This token was revoked on the server.", + "hintHealthy": "Recent deliveries succeeded.", + "openRow": "Состояние доставки", + "reregisterNeedsPermission": "Разрешение на уведомления выключено — включите в настройках системы и повторите.", + "reregisterNeedsDevice": "Не удалось получить push-токен (симуляторы не регистрируются). Попробуйте на устройстве." + }, + "pairedDevices": { + "screenTitle": "Привязанные устройства", + "heading": "Привязанные устройства", + "subtitle": "Устройства, зарегистрированные на этом стримере. Учётные данные не показываются — только имена, права и активность.", + "emptyTitle": "Серверов пока нет", + "emptyBody": "Сначала привяжите сервер, затем управляйте устройствами здесь.", + "loading": "Загрузка устройств…", + "loadFailed": "Не удалось загрузить устройства.", + "retry": "Повторить", + "storeUnavailable": "Реестр устройств недоступен на этом сервере.", + "noDevices": "Устройств пока нет. Привязка из приложения создаёт запись.", + "controlNote": "Устройства с управлением сессией могут вести агента. Отзыв снимает только учётные данные этого устройства — остальные продолжают работать.", + "hasControl": "Это устройство может управлять сессиями (старт, ввод, прерывание).", + "thisDevice": "Это устройство", + "unnamed": "Устройство {{id}}", + "noCapabilities": "Права не указаны", + "createdAt": "Создано {{at}}", + "lastSeen": "Было в сети {{at}}", + "revokedAt": "Отозвано {{at}}", + "revokedSection": "Отозванные", + "revoke": "Отозвать", + "revokeTitle": "Отозвать устройство?", + "revokeBody": "Отозвать «{{name}}»? Доступ пропадёт при следующем запросе.", + "revokeThisBody": "Отозвать учётные данные этого телефона? Общий ключ владельца в приложении останется; инвалидируется только токен устройства.", + "revokeConfirm": "Отозвать", + "revoked": "Устройство отозвано.", + "alreadyRevoked": "Устройство уже отозвано.", + "revokeFailed": "Не удалось отозвать устройство.", + "capability": { + "historyRead": "Чтение истории", + "sessionControl": "Управление сессией", + "fsBrowse": "Просмотр файлов", + "fsUpload": "Загрузка файлов", + "notifications": "Уведомления", + "admin": "Админ" + } + }, + "backup": { + "screenTitle": "Резервная копия", + "heading": "Резервная копия и восстановление", + "subtitle": "Export and restore Threadbase project metadata for this server.", + "emptyTitle": "No servers yet", + "emptyBody": "Pair a server first, then export or restore metadata here.", + "metadataOnly": "Архив содержит только метаданные (идентичность проектов и пути). Разговоры не входят — они в истории провайдера на диске и возвращаются при повторном сканировании.", + "exportSection": "Export", + "restoreSection": "Restore", + "export": "Export now", + "exporting": "Exporting…", + "exported": "Exported {{count}} projects.", + "exportFailed": "Export failed.", + "copy": "Copy JSON", + "share": "Share", + "copied": "Archive copied to clipboard.", + "copyFailed": "Could not copy archive.", + "shareFailed": "Could not share archive.", + "manifestTitle": "Export summary", + "manifestHost": "Host: {{host}}", + "manifestVersion": "Streamer: {{version}}", + "manifestProjects": "Projects: {{count}}", + "manifestCreated": "Created: {{at}}", + "pasteHint": "Paste an archive JSON below, then dry-run before applying.", + "pastePlaceholder": "{ \"manifest\": …, \"projects\": […] }", + "pasteRequired": "Paste an archive JSON first.", + "invalidJson": "Paste is not valid JSON.", + "invalidArchive": "JSON is not a recognized backup archive.", + "pathMapHint": "Optional path rewrite for a machine move (prefix from → to).", + "pathFrom": "From path prefix", + "pathTo": "To path prefix", + "dryRun": "Dry-run restore", + "planning": "Planning…", + "dryRunReady": "Dry-run complete — review the plan, then apply.", + "dryRunFailed": "Dry-run failed.", + "unexpectedApply": "Unexpected apply response from dry-run.", + "apply": "Apply restore", + "applyTitle": "Apply restore?", + "applyBody": "This rewrites project identity on the server. Conversations are not imported from the archive.", + "applyConfirm": "Apply", + "applyFailed": "Apply failed.", + "applied": "Restore applied ({{count}} projects written).", + "conflict": "Restore has path conflicts — resolve them before applying.", + "conflictDetail": "A path is claimed by a different project id. Applying would break one side’s links.", + "conflictRow": "Conflict {{path}} (incoming {{incoming}} vs existing {{existing}})", + "planTitle": "Restore plan", + "planSummary": "Create {{create}} · update {{update}} · conflict {{conflict}}", + "createRow": "Create: {{name}}", + "updateRow": "Update: {{name}}" + } } diff --git a/locales/ru/shared.json b/locales/ru/shared.json index b8accd95..7201c98c 100644 --- a/locales/ru/shared.json +++ b/locales/ru/shared.json @@ -19,25 +19,6 @@ "pinToFavorites": "Закрепить в избранном", "cancel": "Отмена", "loadMore": "+ ещё {{count}}", - "emptyFavorites": "Избранное пусто — долгое нажатие на элемент, чтобы закрепить его.", - "pairServer": "Сопрягите сервер, чтобы увидеть {{tab}}.", - "loading": "Загрузка…", - "loadFailed": "Не удалось загрузить — нажмите для повтора.", - "nothing": "Пока ничего.", - "favorites": "Избранное", - "popular": "Популярное", - "recents": "Недавнее" - }, - "conversationInfo": { - "title": "Информация о беседе", - "id": "ID", - "titleLabel": "НАЗВАНИЕ", - "projectPath": "ПУТЬ ПРОЕКТА", - "messageCount": "КОЛИЧЕСТВО СООБЩЕНИЙ", - "lastActivity": "ПОСЛЕДНЯЯ АКТИВНОСТЬ" - }, - "session": { - "active": "Активна", - "gotIt": "Понятно" + "emptyFavorites": "Избранное пусто — долгое нажатие на элемент, чтобы закрепить его." } } diff --git a/locales/ru/terminal.json b/locales/ru/terminal.json index 8512a0b8..cdba7574 100644 --- a/locales/ru/terminal.json +++ b/locales/ru/terminal.json @@ -1,7 +1,4 @@ { - "header": { - "session": "Сессия" - }, "connection": { "reconnectingTitle": "Переподключение…", "reconnectingMessage": "Соединение потеряно — содержимое ниже может быть устаревшим.", @@ -9,38 +6,24 @@ "stalledMessage": "Подключено, но вывод давно не поступал." }, "action": { - "copyId": "Копировать ID сессии", "sendInput": "Отправить ввод", - "cancel": "Отменить сеанс", - "more": "Ещё", "stop": "Остановить сеанс" }, "voice": { "start": "Начать диктовку", - "stop": "Остановить диктовку", - "permissionDeniedTitle": "Доступ к микрофону запрещён", - "permissionDeniedBody": "Threadbase требуется доступ к микрофону для диктовки промптов. Включите его в настройках." + "stop": "Остановить диктовку" }, "dialog": { "cancelTitle": "Отменить сессию", "cancelMessage": "Вы уверены, что хотите отменить эту сессию?", "cancelConfirm": "Отменить сессию", - "cancelDismiss": "Продолжить", "removeTitle": "Удалить сессию", - "removeMessage": "Вы уверены, что хотите удалить эту сессию?", "removeConfirm": "Удалить", - "removeDismiss": "Оставить", "stopTitle": "Остановить сеанс", - "stopMessage": "Остановить этот сеанс? Claude будет прерван.", - "stopConfirm": "Остановить", - "stopDismiss": "Продолжить", "stopFailed": "Не удалось остановить сеанс. Проверьте подключение и попробуйте снова." }, "status": { - "connecting": "Подключение…", - "reconnecting": "Переподключение…", "disconnected": "Отключено", - "waking": "Пробуждение…", "starting": "Запуск сессии…", "stuckTitle": "Всё ещё запускается…", "stuckBody": "Сессия пока не достигла своего запроса. Вы можете смотреть вывод её консоли в реальном времени, не дожидаясь дальше.", @@ -48,11 +31,12 @@ "waitMore": "Подождать ещё" }, "input": { - "placeholder": "Отправить сообщение…", - "send": "Отправить" + "placeholder": "Отправить сообщение…" }, "session": { "notFound": "Сессия не найдена", + "notFoundBody": "Этой сессии больше нет на сервере. Возможно, она завершилась или была удалена.", + "backToHub": "Назад в хаб", "ended": "Сессия завершена", "endedBody": "Эта сессия завершилась, не выдав никакого вывода.", "failedToStart": "Не удалось запустить сессию", @@ -68,10 +52,13 @@ "prompts_few": "{{count}} промпта", "prompts_many": "{{count}} промптов", "prompts_other": "{{count}} промптов", - "tabTerminal": "Терминал", - "tabChat": "Чат" + "viewModeTerminal": "Терминал", + "viewModeChat": "Чат", + "rawFallbackBanner": "Показан сырой терминал — нормализация чата ненадёжна для этого потока.", + "ptyActiveFallbackBanner": "Живой вывод терминала активен до появления сообщений беседы.", + "rawModeNote": "Сырой терминал — обнаружены неподдерживаемые последовательности; вывод без фильтра." }, - "banner": { - "streamingInfo": "Вызовы инструментов и вывод передаются здесь в реальном времени. Введите продолжение ниже, пока Claude ещё работает." + "a11y": { + "line": "Строка {{n}}: {{text}}" } } diff --git a/log-mobile.txt b/log-mobile.txt deleted file mode 100644 index 3429b618..00000000 --- a/log-mobile.txt +++ /dev/null @@ -1,84 +0,0 @@ - -› Scan the QR code above to open in a development build. (Learn more: https://expo.fyi/start) -› Metro: exp+threadbase-mobile://expo-development-client/?url=https%3A%2F%2Fmetro.rbv1000.win - -› Using development build (Press s to switch to Expo Go) -› Press ? │ show all commands - -Logs for your project will appear below. Press Ctrl+C to exit. -iOS Bundled 1845ms node_modules/expo-router/entry.js (5476 modules) - WARN Route "./session/[id].tsx" is missing the required default export. Ensure a React component is exported as default. - ERROR [Error: Cannot find native module 'ExpoDevice'] - -Code: useVoiceInput.ts - 1 | import { useCallback, useEffect, useRef, useState } from 'react' - 2 | import { Platform } from 'react-native' -> 3 | import * as Device from 'expo-device' - | ^ - 4 | import { - 5 | ExpoSpeechRecognitionModule, - 6 | useSpeechRecognitionEvent, -Call Stack - <global> (hooks/useVoiceInput.ts:3) - <global> (hooks/useComposerState.ts:4) - <global> (components/conversation/LiveConversationView.tsx:12) - <global> (app/session/[id].tsx:44) - ERROR [TypeError: undefined is not a function] - -Code: NativeProxy.ts - 63 | }, - 64 | installUIRuntimeBindings: () => { -> 65 | return RNGestureHandlerModule.installUIRuntimeBindings(); - | ^ - 66 | }, - 67 | } as const; - 68 | -Call Stack - installUIRuntimeBindings (node_modules/react-native-gesture-handler/src/v3/NativeProxy.ts:65:59) - ghQueueMicrotask$argument_0 (node_modules/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts:95:59) - apply (<native>) - global.queueMicrotask$argument_0 (node_modules/react-native/Libraries/Core/Timers/immediateShim.js:47:21) -iOS Bundled 152ms node_modules/@react-native-async-storage/async-storage/src/index.ts (580 modules) - ERROR [TypeError: undefined is not a function] - -Code: attachHandlers.ts - 63 | } - 64 | for (const handler of gesturesToAttach) { -> 65 | RNGestureHandlerModule.setGestureHandlerConfig( - | ^ - 66 | handler.handlerTag, - 67 | filterConfig(handler.config, ALLOWED_PROPS) - 68 | ); -Call Stack - ghQueueMicrotask$argument_0 (node_modules/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts:65:53) - apply (<native>) - global.queueMicrotask$argument_0 (node_modules/react-native/Libraries/Core/Timers/immediateShim.js:47:21) - ERROR [TypeError: undefined is not a function] - -Code: updateHandlers.ts - 66 | handler.handlers = newGestures[i].handlers; - 67 | -> 68 | RNGestureHandlerModule.setGestureHandlerConfig( - | ^ - 69 | handler.handlerTag, - 70 | filterConfig(handler.config, ALLOWED_PROPS) - 71 | ); -Call Stack - ghQueueMicrotask$argument_0 (node_modules/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts:68:53) - apply (<native>) - global.queueMicrotask$argument_0 (node_modules/react-native/Libraries/Core/Timers/immediateShim.js:47:21) - ERROR [TypeError: undefined is not a function] - -Code: updateHandlers.ts - 66 | handler.handlers = newGestures[i].handlers; - 67 | -> 68 | RNGestureHandlerModule.setGestureHandlerConfig( - | ^ - 69 | handler.handlerTag, - 70 | filterConfig(handler.config, ALLOWED_PROPS) - 71 | ); -Call Stack - ghQueueMicrotask$argument_0 (node_modules/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts:68:53) - apply (<native>) - global.queueMicrotask$argument_0 (node_modules/react-native/Libraries/Core/Timers/immediateShim.js:47:21) - diff --git a/package-lock.json b/package-lock.json index 7a01eaf9..4b103071 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,10 @@ "": { "name": "threadbase-mobile", "version": "1.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@expo/metro-runtime": "~57.0.3", + "@expo/metro-runtime": "~57.0.7", "@gorhom/bottom-sheet": "^5.2.13", "@react-native-async-storage/async-storage": "3.1.1", "@react-native-community/netinfo": "12.0.1", @@ -19,32 +20,32 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-persist-client": "^5.101.1", "clsx": "^2.1.1", - "expo": "^57.0.1", - "expo-blur": "~57.0.0", - "expo-build-properties": "~57.0.2", - "expo-camera": "~57.0.1", - "expo-clipboard": "~57.0.0", - "expo-constants": "~57.0.2", - "expo-dev-client": "~57.0.3", - "expo-device": "~57.0.0", - "expo-document-picker": "~57.0.0", - "expo-font": "~57.0.0", - "expo-haptics": "~57.0.0", - "expo-image-manipulator": "~57.0.1", - "expo-image-picker": "~57.0.1", + "expo": "^57.0.8", + "expo-blur": "~57.0.2", + "expo-build-properties": "~57.0.7", + "expo-camera": "~57.0.3", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.7", + "expo-dev-client": "~57.0.8", + "expo-device": "~57.0.1", + "expo-document-picker": "~57.0.1", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.1", + "expo-image-manipulator": "~57.0.6", + "expo-image-picker": "~57.0.6", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.1", - "expo-local-authentication": "~57.0.0", - "expo-localization": "~57.0.0", - "expo-mail-composer": "^57.0.0", - "expo-notifications": "~57.0.2", - "expo-router": "~57.0.6", - "expo-secure-store": "~57.0.0", + "expo-linking": "~57.0.4", + "expo-local-authentication": "~57.0.2", + "expo-localization": "~57.0.1", + "expo-mail-composer": "~57.0.1", + "expo-notifications": "~57.0.7", + "expo-router": "~57.0.8", + "expo-secure-store": "~57.0.1", "expo-speech-recognition": "56.0.1", - "expo-splash-screen": "~57.0.1", - "expo-status-bar": "~57.0.0", - "expo-system-ui": "~57.0.0", - "expo-updates": "~57.0.4", + "expo-splash-screen": "~57.0.5", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.1", + "expo-updates": "~57.0.9", "i18next": "^26.3.6", "intl-pluralrules": "^2.0.1", "nativewind": "^4.2.6", @@ -62,7 +63,7 @@ "react-native-pager-view": "8.0.2", "react-native-reanimated": "4.5.0", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "4.26.2", "react-native-svg": "15.15.5", "react-native-web": "^0.21.2", "react-native-worklets": "0.10.2", @@ -85,6 +86,7 @@ "expo-mcp": "~0.2.1", "jest": "^29.7.0", "jest-expo": "~57.0.0", + "patch-package": "^8.0.1", "react-refresh": "^0.18.0", "react-test-renderer": "19.2.7", "tailwindcss": "^3.4.10", @@ -1571,6 +1573,232 @@ "integrity": "sha512-BKNSJ6oFGWi+aXu6PF+1EdI43J8Ag+Sm7Ste1SXYmC2eYOJasyjXHkKjgkIieBB1eDZQJSPWuPv421Vg6N5tCQ==", "license": "MIT AND Apache-2.0" }, + "node_modules/@expo/cli": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.10.tgz", + "integrity": "sha512-mP+B9ZrTnRE94bxPM/kCVKPyuVuOTRvvsqbXVxdXtrAfOfF94YIZLGUtw/151cGFtdUPbsBsJ9gW02LhU+QMZA==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~57.0.6", + "@expo/config-plugins": "~57.0.6", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.4.2", + "@expo/image-utils": "^0.11.4", + "@expo/inline-modules": "^0.1.3", + "@expo/json-file": "^11.0.1", + "@expo/log-box": "^57.0.1", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~57.0.7", + "@expo/metro-file-map": "^57.0.1", + "@expo/osascript": "^2.7.1", + "@expo/package-manager": "^1.13.1", + "@expo/plist": "^0.8.1", + "@expo/prebuild-config": "^57.0.9", + "@expo/require-utils": "^57.0.4", + "@expo/router-server": "^57.0.4", + "@expo/schema-utils": "^57.0.2", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.86.0", + "accepts": "^1.3.8", + "agent-cli-detector": "^0.1.2", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^57.0.1", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "main.js" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@expo/cli/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@expo/cli/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@expo/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/cli/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@expo/cli/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@expo/cli/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/@expo/cli/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -1581,15 +1809,15 @@ } }, "node_modules/@expo/config": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.1.tgz", - "integrity": "sha512-qj/OJ508aiRxoc8Ca9Ku8snDkiu5XjAx35vGP282CoQiUViM6RI3zAJUkOx7xpEzlj2z7mlbMBxLBy1Nf4v1Ug==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.6.tgz", + "integrity": "sha512-VpMJpB/De/fb9bBFVVBiK6Ntg9lt0kAleLH9hcZz85CYRUQ3jVFVA8rNC5f8y4cp2+FiiPNFp62+kEOFI6pDiw==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.1", - "@expo/config-types": "^57.0.1", - "@expo/json-file": "^11.0.0", - "@expo/require-utils": "^57.0.0", + "@expo/config-plugins": "~57.0.6", + "@expo/config-types": "^57.0.2", + "@expo/json-file": "^11.0.1", + "@expo/require-utils": "^57.0.4", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1599,15 +1827,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.1.tgz", - "integrity": "sha512-X8qOqWAhTWBEyxiSWV7jdXvV3R2rCBd2/GKK+crh31H/KzOGwV0ddHTQwgP3WjvdFzU4f8jQIoym7mHnz5eP1w==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.6.tgz", + "integrity": "sha512-7CmKrS5Rnu8aSZyNlxH2qzA7Ls1HEa4EQvEVOAkHDKPr1e4Cg/nz7I7dUl09QDTVjMvkKYDH4Th0DuAsgqASaw==", "license": "MIT", "dependencies": { - "@expo/config-types": "^57.0.1", - "@expo/json-file": "~11.0.0", - "@expo/plist": "^0.8.0", - "@expo/require-utils": "^57.0.0", + "@expo/config-types": "^57.0.2", + "@expo/json-file": "~11.0.1", + "@expo/plist": "^0.8.1", + "@expo/require-utils": "^57.0.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1632,9 +1860,9 @@ } }, "node_modules/@expo/config-types": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-57.0.1.tgz", - "integrity": "sha512-fo7d/Ym28uwGzdTV2leEvpsb9t+7i8YHjy471m31+cmDt4BRd/l7e94JHyrXAq4SWOBVus16S0JLqm89SRCCdw==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-57.0.2.tgz", + "integrity": "sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==", "license": "MIT" }, "node_modules/@expo/config/node_modules/semver": { @@ -1669,9 +1897,9 @@ } }, "node_modules/@expo/devtools": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-57.0.0.tgz", - "integrity": "sha512-i8ITQmf/wB0JNQ2gASFOKvqo1zRWCl/VfPlFRXdz6UkHen4s31NmMy0zmumS+pMpwn0+gA6oU4FF4zRDRG488Q==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-57.0.1.tgz", + "integrity": "sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==", "license": "MIT", "dependencies": { "chalk": "^4.1.2" @@ -1701,9 +1929,9 @@ } }, "node_modules/@expo/env": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.0.tgz", - "integrity": "sha512-tSlakVnFqdWlXnw5KCFSC1rQU8W5l0Ko8Pb96wi5XU4lCqc0UeLuZCQFpCeAhZGmd91Lo89mCNmr5tnXzZwtFA==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.2.tgz", + "integrity": "sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1721,12 +1949,12 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.1.tgz", - "integrity": "sha512-cSI7UsxcaaQAxykEZkiwX9c+sw976JBQpBN16fFgpFJVA92CfSpEIO0EcjVEXBrIZWfETxMJ6iyNk0gSu8cEzg==", + "version": "0.20.6", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.6.tgz", + "integrity": "sha512-cmC/6BOPRbdKr77Mgjwszb8aM0hY2RKBpMRCmjSdn9zIcn2FGor/ic4fHVr46cQFa1G6RDGg1GyAjRw3US4CCQ==", "license": "MIT", "dependencies": { - "@expo/env": "^2.4.0", + "@expo/env": "^2.4.2", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", @@ -1770,12 +1998,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.0.tgz", - "integrity": "sha512-tXFTMAl79rOiK3q8cCw00CioMxv1XfM31B6B+9IK/yeWKJrwny8sSIJ0IzZWqHy6F23rW5AQVIyT5PJZ5p/hmA==", + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.4.tgz", + "integrity": "sha512-pn/4770DIEOcYZr484uazuwg20FX/qaDkeMRF6J+oxejynDmEmO8wLsCudaNShFE0BhyKGQTYrs2rsRhqrqESw==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.0", + "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1797,18 +2025,18 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.1.tgz", - "integrity": "sha512-Jbp1d6LSOS2RZCrNeK6JehcRGcYGb+1xNsHUfjggMWMlz/J2SP3HHKg8cbTkVj7KKPn33AkyFBaSqlMsvrp7ow==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.3.tgz", + "integrity": "sha512-eHSxWYfgq65mP3Qz8PclVjUkSrDIlGl3va9U7PMcTpGItOvee/i0ZzGinH5A25oARR5ouD64eESBKwtT/CwdHg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.1" + "@expo/config-plugins": "~57.0.5" } }, "node_modules/@expo/json-file": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.0.tgz", - "integrity": "sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", + "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1816,12 +2044,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.1.tgz", - "integrity": "sha512-GU6VD75wXlOIK9myzHPhquLNDwWsr8IvnupmdifHIKcT9OdHWXaxIejFjf7iOX8ukaAPRnUnKwGNUQF0zKhjkQ==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.4.tgz", + "integrity": "sha512-B/cI73shkLSYBYuFyh+zCbS+WhqJgawWPW4MPdMiNLJKv9RmV4dv1FGjsidiIiG2k4kYKerBDLK4bLbC7qERQQ==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.1", + "@expo/config": "~57.0.5", "chalk": "^4.1.2" } }, @@ -1890,19 +2118,19 @@ } }, "node_modules/@expo/metro-config": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.2.tgz", - "integrity": "sha512-jVCMnOJWpgt5Ssm7zN1Mk7rH+K8bKVGX01xfyOTN3eF1TFRhUCJpt8QuQpnFYdV1WhCPcIpdt2HtC72527VZnw==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.7.tgz", + "integrity": "sha512-bVfEkg4zF1cA62OqAdYXmFOooJ6TB/I+REi7Se6Ct+PbSC+89TwSqWXnYx34L08eIs4z+1ilgbATakTZpgefmQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~57.0.1", - "@expo/env": "~2.4.0", - "@expo/json-file": "~11.0.0", + "@expo/config": "~57.0.6", + "@expo/env": "~2.4.2", + "@expo/json-file": "~11.0.1", "@expo/metro": "~56.0.0", - "@expo/require-utils": "^57.0.0", + "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", @@ -1928,25 +2156,10 @@ } } }, - "node_modules/@expo/metro-config/node_modules/hermes-estree": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", - "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", - "license": "MIT" - }, - "node_modules/@expo/metro-config/node_modules/hermes-parser": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", - "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.36.1" - } - }, "node_modules/@expo/metro-file-map": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.0.tgz", - "integrity": "sha512-/sxwKQmwpYFYjFT6VYFpgldTwSv53OCx4/UCpLCv9iqQLVQ30hFEJsxGA9Jif8n9pacigK4P5/0ZJ2zMHm6arA==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.1.tgz", + "integrity": "sha512-8JXfVstZN7QnP4NianZZnlTVboOWR0sG8trUDNajOjnbGlPln29vponXM84tY+3tAHapz5/TxE53L0ixUwqPtA==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1958,9 +2171,9 @@ } }, "node_modules/@expo/metro-runtime": { - "version": "57.0.5", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.5.tgz", - "integrity": "sha512-OBHbGgLeDROnoeWO2td9ZS9EFC5ADzlXaAVM4sdYDe8vrmOb4rIuSrwBSjnkmr7svwzqAhX0hg089A1bQvOu7g==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.7.tgz", + "integrity": "sha512-95UeoN/YsLellvskKsFGN9vKBwNc5k70ysO3skqfL3VusWlYIiYmPY+MpEWNz8A8W2CjLg9AKwZKAGrFj6znQg==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.1", @@ -2015,9 +2228,9 @@ "license": "MIT" }, "node_modules/@expo/osascript": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", - "integrity": "sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", + "integrity": "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.8.0" @@ -2027,12 +2240,12 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.0.tgz", - "integrity": "sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.1.tgz", + "integrity": "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==", "license": "MIT", "dependencies": { - "@expo/json-file": "^11.0.0", + "@expo/json-file": "^11.0.1", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -2041,9 +2254,9 @@ } }, "node_modules/@expo/plist": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.8.0.tgz", - "integrity": "sha512-24JlUJI4PwHN4PLydlzFEzCdiqybfaV5t04QBkOg8em3AjvHKbMgBGlKreiuOoc0rNa3DZ21ZqL+xLGMBLQNKQ==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.8.1.tgz", + "integrity": "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==", "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", @@ -2052,19 +2265,19 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.3.tgz", - "integrity": "sha512-jMMLZW+2OG/1FY3qCHzI1uMOnt2tIjIW3A6+FXz4rHtyZmD6SJvtuGEP/AZiegqAvOPX+PBWgFXuCzXvyIj6kg==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.9.tgz", + "integrity": "sha512-8g7RoXFvO/dxvLzRE/bvphzDL4bfV0w3/4Aj6DfwvgymZ1ULz5gW2x0js94opZRoZvWw4SolH6/74hLlZT3rAA==", "license": "MIT", "dependencies": { - "@expo/config": "~57.0.1", - "@expo/config-plugins": "~57.0.1", - "@expo/config-types": "^57.0.1", - "@expo/image-utils": "^0.11.0", - "@expo/json-file": "^11.0.0", + "@expo/config": "~57.0.6", + "@expo/config-plugins": "~57.0.6", + "@expo/config-types": "^57.0.2", + "@expo/image-utils": "^0.11.4", + "@expo/json-file": "^11.0.1", "@react-native/normalize-colors": "0.86.0", "debug": "^4.3.1", - "expo-modules-autolinking": "~57.0.3", + "expo-modules-autolinking": "~57.0.9", "resolve-from": "^5.0.0", "semver": "^7.6.0" } @@ -2082,9 +2295,9 @@ } }, "node_modules/@expo/require-utils": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.0.tgz", - "integrity": "sha512-6PPvmqdAz7ikxJUwg+g/JV1KKQJS1xkkh/r+z4tucvBelrM0vMnENV2QVVn4qDWS0FfSIcwuSYvvx+4od/Sa9w==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.4.tgz", + "integrity": "sha512-e7xbg/9BTQcsZE/oErafZXtI7kh5IgfasLJ97J5sFSzX2cA74pDvdlhW1KHVSaDkQyQv6h1LSLhsY7dEeOk7hw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -2092,7 +2305,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2100,6 +2313,40 @@ } } }, + "node_modules/@expo/router-server": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.4.tgz", + "integrity": "sha512-ucqCP0hK8nZb9+S8QJYdQxNCkfRClzkKdg2RpWYDKDYgRIHyoRyxEDRgDgvbhH+q78yfY83abU8OTx8MMOrf1g==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^57.0.7", + "expo": "*", + "expo-constants": "^57.0.7", + "expo-font": "^57.0.1", + "expo-router": "*", + "expo-server": "^57.0.1", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, "node_modules/@expo/schema-utils": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-57.0.2.tgz", @@ -2131,9 +2378,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.6.tgz", - "integrity": "sha512-jmgU43PXq5u43DpXsjDJHNaUO6f3jxpShurwg0KTVoV4nxx2DNgVp5vRGUqqTGbpDaHlZXXsdg69jdM/umPhVA==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.7.tgz", + "integrity": "sha512-WqRVabl8VpHf3+YLHVjUy7PMIuXXI6DG88Vgmavro7Nd8Ks13h9sEJH/RLSCaJE2daVnqzEKY1v797BibuY9aw==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -3034,23 +3281,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", - "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -3070,15 +3318,30 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3090,13 +3353,31 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", - "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3114,12 +3395,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -3137,12 +3418,32 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3154,6 +3455,39 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", @@ -3169,70 +3503,133 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", - "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" - }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3244,9 +3641,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3259,14 +3656,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", - "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3283,13 +3680,28 @@ } } }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -3307,13 +3719,28 @@ } }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3343,13 +3770,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", - "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3366,13 +3793,28 @@ } } }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -3390,13 +3832,28 @@ } }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -5288,6 +5745,13 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -5380,6 +5844,18 @@ "node": ">= 6.0.0" } }, + "node_modules/agent-cli-detector": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz", + "integrity": "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5906,9 +6382,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.1.tgz", - "integrity": "sha512-ClW79dx27GJVwKf/YMKCrR18uer6jlREZtKVB00HQMIoyeM5Qxh9axKTT0GUNFkFq596wi216ovp1zcGMUhE8g==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.4.tgz", + "integrity": "sha512-EkFcNoE23HVzQT6ZNXs/adN8+G7rqEAF6tQn6LpRPYa1YY7wmX5GxhJF0kaYMNtBndNrMTN4+0rYE17VG13KFg==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -5957,7 +6433,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^57.0.1", + "expo-widgets": "^57.0.6", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -8400,31 +8876,31 @@ "license": "MIT" }, "node_modules/expo": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.1.tgz", - "integrity": "sha512-cJCgOAojkuHKKSWVuH2LXQrvYGZNQO0fTDMLUP9gDv2JWEAwIoFHlWocgZ/8HUvUQwMaF+C/82ZbVLbtwsp3cg==", + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.8.tgz", + "integrity": "sha512-0IxxoPZbT54IH4fHL5NihkvED9HBVQx3uNdPvyv8pFUHWJ81RdFjL0aJys1IB7hbo09KTz4xW4I2aWVOXKAcJQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.3", - "@expo/config": "~57.0.1", - "@expo/config-plugins": "~57.0.1", - "@expo/devtools": "~57.0.0", - "@expo/dom-webview": "~57.0.0", - "@expo/fingerprint": "^0.20.1", - "@expo/local-build-cache-provider": "^57.0.1", - "@expo/log-box": "^57.0.0", + "@expo/cli": "^57.0.10", + "@expo/config": "~57.0.6", + "@expo/config-plugins": "~57.0.6", + "@expo/devtools": "~57.0.1", + "@expo/dom-webview": "~57.0.1", + "@expo/fingerprint": "^0.20.6", + "@expo/local-build-cache-provider": "^57.0.4", + "@expo/log-box": "^57.0.1", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~57.0.2", + "@expo/metro-config": "~57.0.7", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~57.0.1", - "expo-asset": "~57.0.2", - "expo-constants": "~57.0.2", - "expo-file-system": "~57.0.0", - "expo-font": "~57.0.0", - "expo-keep-awake": "~57.0.0", - "expo-modules-autolinking": "~57.0.3", - "expo-modules-core": "~57.0.1", + "babel-preset-expo": "~57.0.4", + "expo-asset": "~57.0.7", + "expo-constants": "~57.0.7", + "expo-file-system": "~57.0.1", + "expo-font": "~57.0.1", + "expo-keep-awake": "~57.0.1", + "expo-modules-autolinking": "~57.0.9", + "expo-modules-core": "~57.0.7", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -8462,22 +8938,22 @@ } }, "node_modules/expo-application": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.0.tgz", - "integrity": "sha512-vNrnYlqtRS5pFUm1tjut4dogZxW/uhmMOD5GeLQ8Z9Ji3KMYKLOy5dG0BlUsMrcSgyPJvJqP0TFopkEZ02ec6g==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz", + "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-asset": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.2.tgz", - "integrity": "sha512-58QjXdeklQmauYS7VlxFAk2O/KNsHqpfplHKLcgDQhg3ek26+3LL+lxWwha92JOBcHNUQoqPN47SoEsD2Yjj8A==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.7.tgz", + "integrity": "sha512-TA6MRQq1HL0N6KGvNMzL6djdH5tGJ/eHPIhML+6bVu52mnXldmup2swdvP37zDnvoMUUXRVGsvwVHFmvhCbW6Q==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.11.0", - "expo-constants": "~57.0.2" + "@expo/image-utils": "^0.11.4", + "expo-constants": "~57.0.7" }, "peerDependencies": { "expo": "*", @@ -8486,9 +8962,9 @@ } }, "node_modules/expo-blur": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.0.tgz", - "integrity": "sha512-cn+LlmOdf7swx80v6yngW9AVl7m1F4cQGpfeJx/Jckgb6C4/sdA5jy/f4wBJCGCZs+2qpPEjlUkQ2Q2+7LQiuA==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.2.tgz", + "integrity": "sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8497,12 +8973,12 @@ } }, "node_modules/expo-build-properties": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.2.tgz", - "integrity": "sha512-6S1tXlb05sLNmBn5dyIgSNPlP22pGNuw2OeTxN7BXjeiQmfMJh0pZZGdmdi9d1KIhNWfbWnvffcfnoINaPm2vA==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.7.tgz", + "integrity": "sha512-yo/2rp7tmWdmwHa2cA2EgOQNKmihNivTAiB9Dcg2s20b4g+QasGDyq4aOmwifUaLjK9bIRUs4D4xX5HzjSL1Hw==", "license": "MIT", "dependencies": { - "@expo/schema-utils": "^57.0.0", + "@expo/schema-utils": "^57.0.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, @@ -8523,9 +8999,9 @@ } }, "node_modules/expo-camera": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.1.tgz", - "integrity": "sha512-OtQOOFQ8djlB/UXlETa+Agu9m88P6d1go0VIO9b/a54AFUuowUqEH9vpzkq5iJ4jVhvJSblm/II7kLtwNnxW7Q==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.3.tgz", + "integrity": "sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==", "license": "MIT", "dependencies": { "barcode-detector": "^3.0.0" @@ -8543,9 +9019,9 @@ } }, "node_modules/expo-clipboard": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.0.tgz", - "integrity": "sha512-eIt47f6kvR33HOnIEWvs9l1AUAY4GeLY27szwamSuwRl7nntNythSFFbmnrEtiSUaz//y8XVqCG9WLpdMkOpjA==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.1.tgz", + "integrity": "sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8554,12 +9030,12 @@ } }, "node_modules/expo-constants": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.2.tgz", - "integrity": "sha512-Bq0lIOB+olAQrYCS/fZi0cu1i8V0lo/YNr9fqMELerOfYYFkg8zTVrVaRjdFdMYBmz3dtym26UEBxV5dyUYZOg==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", + "integrity": "sha512-ShDwaKnh3UieCQ/dG0kO8PuicTTatn3WDGmXbq/fukyzPXWhdUxf37DINVDQS6D3DDG7nfqItij+QvnDHSQhTg==", "license": "MIT", "dependencies": { - "@expo/env": "~2.4.0" + "@expo/env": "~2.4.2" }, "peerDependencies": { "expo": "*", @@ -8567,30 +9043,30 @@ } }, "node_modules/expo-dev-client": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.3.tgz", - "integrity": "sha512-GcxWICRD3Y55bwV3/+FHsQvsNUQ7apQzWzeqUrr/TCsmn8JgCVHhIfHSiuW++r9+zVKtmtQ0uryEd8yXwWj7jg==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.9.tgz", + "integrity": "sha512-DDqinc0EHMsqDUqkfad6c3pc5vr2oCxwAojSGVSBZe5paRIPgQTEWA+x76Qel13F1LO2LSG+DLs5W1vk3JwUow==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~57.0.3", - "expo-dev-menu": "~57.0.3", + "expo-dev-launcher": "~57.0.9", + "expo-dev-menu": "~57.0.9", "expo-dev-menu-interface": "~57.0.0", - "expo-manifests": "~57.0.0", - "expo-updates-interface": "~57.0.0" + "expo-manifests": "~57.0.1", + "expo-updates-interface": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-launcher": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.3.tgz", - "integrity": "sha512-JBGCEsPBV5I/MgXTmEEMSD+LHxEJjMgsj0oqLwMXyM74+j3hAgEeeXwV9nzuggWWO1f8O6S06p8IykXmaMoueQ==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.9.tgz", + "integrity": "sha512-f2LhS6FaQBKACSNQ1opPR7bg6ehAJnrDuEkewqINJkTDryhGY7Y6P21uoXWJzEc9IFJAjUKan8A8AOztibGtMQ==", "license": "MIT", "dependencies": { - "@expo/schema-utils": "^57.0.0", - "expo-dev-menu": "~57.0.3", - "expo-manifests": "~57.0.0" + "@expo/schema-utils": "^57.0.2", + "expo-dev-menu": "~57.0.9", + "expo-manifests": "~57.0.1" }, "peerDependencies": { "expo": "*", @@ -8598,9 +9074,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.3.tgz", - "integrity": "sha512-WGhoVhXhunXWG00GUXTZrW9Z3z1YPTKb4kRN0Mvn9hPtXG00+OqjlnIqOjQTEJjLOcc9PNNiQsZX6Gif4DWjCA==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.9.tgz", + "integrity": "sha512-OGU3Apwx1fr8HDUD9F66tk/CuxiIhYkHci4837vX7ZckS6yI/yL6gvcmgqvNCoE4pDV9demjaGEZmSJ2xTrUhg==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "~57.0.0" @@ -8620,9 +9096,9 @@ } }, "node_modules/expo-device": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.0.tgz", - "integrity": "sha512-XiTtUhyS64xO8AtEY14FI1vJpUT5b+cyxhjAsHIsoAx6y4rLF719pVY2sNx1MJYb/l7qa9kxWAcMDfk8Q8Gcpg==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.1.tgz", + "integrity": "sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA==", "license": "MIT", "dependencies": { "ua-parser-js": "^0.7.33" @@ -8632,24 +9108,24 @@ } }, "node_modules/expo-document-picker": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.0.tgz", - "integrity": "sha512-foT1pA3tD7KvcqXwjQBh4qEDjHT9U9MhZ6H2aF38VolCiHRhXDQLtKQqCeXjbHxJhSf8HC/YPV3IRxDxFTL2sA==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.1.tgz", + "integrity": "sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-eas-client": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.0.tgz", - "integrity": "sha512-HYroDzqYFpALIXW5tpQD2WHqij3iUal+fXzVJhIYV3OdVcbobxt+p6209OJDARlU431CANZwp4vPmS7yRCWTCw==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-57.0.1.tgz", + "integrity": "sha512-4w51+zsl/ziUHQMJgLgUdgsNhRPAwHBfySpPB1hpWU21X74QS9T4SqDftaRnrDagn/DfcrXUMJvHbDQUxLPJNA==", "license": "MIT" }, "node_modules/expo-file-system": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.0.tgz", - "integrity": "sha512-G+eytNuNGMiS7dbdj1j0nAYMowXnNr1vpO+jss6nQvBlRxshcGBFMtk8xfc67ynz0MUVkzod7WwKO7Vf1iOaJw==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.1.tgz", + "integrity": "sha512-w7/ERvQFrGP2apTO9lDtZ+O6JQIhfakL7+Xqzh+rfMO9B4LB4qwrz+YvLgir8KFRVX64JHBnRuYBVLY1oQZcqw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8657,9 +9133,9 @@ } }, "node_modules/expo-font": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.0.tgz", - "integrity": "sha512-zF+J7WrNFjqyAADwdvDgkFEoIQv9DqcjJ57HVstNEH7/7Tx1ThPEhKraodEQOwSjMYWirP+4BYsVbdb+/Zr4QQ==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz", + "integrity": "sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ==", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" @@ -8682,57 +9158,57 @@ } }, "node_modules/expo-haptics": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.0.tgz", - "integrity": "sha512-va+uB/DoMlT9f0OJ8v24iNqs+YH6Q6wip+LLjVvalbJzGK8LvZQ+gIiauogFxAAFLQIJzrQVJGf+0BmV3q13bQ==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.1.tgz", + "integrity": "sha512-8VhbnxlIrfXjP0syZr1JT197nafYicQu9119adOJnX62osU9Cw+PdDnAx/6LxuKJRzQdwxOMq7b7eWjhNL5zAQ==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-image-loader": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.0.tgz", - "integrity": "sha512-EhwnoPC4T/EMdB7Nsg7qITzhO/qB0hUnmVghmtAKQwwDVaLWWYCGE4NLkes3zk9Ub451SmS/swgPp4PCPzOlnw==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-image-manipulator": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.1.tgz", - "integrity": "sha512-6M+Ii1JlaOZxaJgvEpzJ6omKr6RxCnp3rAS3nyninGE9eX+SUFjvpCnidasQHhfZb0wBpzq3MXJI6T4rJ2WWUg==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.6.tgz", + "integrity": "sha512-9eg0nTC0LRwnPgLajs27FoZvlvj1uSmn9dtADzR9RmnrsGLn345llMqm29IbN+WSbdHJF7E3GlengThLdKAQLg==", "license": "MIT", "dependencies": { - "expo-image-loader": "~57.0.0" + "expo-image-loader": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-image-picker": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.1.tgz", - "integrity": "sha512-DHwFK48ZKkHbGKWgamwfedK/THpM2lvqIFADi60F7EnO5D7limVFz5ZR6UPCa+CKKmNNE7D5wW36oNs61372qA==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.6.tgz", + "integrity": "sha512-6Of7SzyFVC+WFuFxhD4+nRTQ9joqldPhbiVWRsumg4ybttKmY8GxrnXAUSIDfa9DSk9PhgIAy2C1c5y1+CnU3g==", "license": "MIT", "dependencies": { - "expo-image-loader": "~57.0.0" + "expo-image-loader": "~57.0.1" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-json-utils": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.0.tgz", - "integrity": "sha512-GJMjJlS3ZRTXWkKJPXY9OjscEEyPxbvNURBJ9Gkd3KxS4Hzof+EdK8pF+3Ty4ec435ciYm9ll+IWznuVHYMiow==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz", + "integrity": "sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==", "license": "MIT" }, "node_modules/expo-keep-awake": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.0.tgz", - "integrity": "sha512-WqEoyDNSmUeAI9Gu7UaWKDjOhfaV+jGcms7N0hh/EAr7sqJrI2s0HpLSC3P9cWfXFUZCL1zZjd22m/NbsJYCKg==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz", + "integrity": "sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8751,12 +9227,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.1.tgz", - "integrity": "sha512-3Zr+UsTXPiUyGktGqqP0byXH02Yp7lsdmasenuZAMEn89cjD8Rs9j8o7kikjOzb+QnTR1lofgYu21QhpvCf6zg==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.4.tgz", + "integrity": "sha512-e1alfHNJdywIfJkCuKMc6M3hBfAGPd2gKMeF/6V7qwFWzHCS2mTBqU+KaO4FLpltA5Nt6CYEx6zmUlGfUF+8lA==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.2", + "expo-constants": "~57.0.7", "invariant": "^2.2.4" }, "peerDependencies": { @@ -8765,9 +9241,9 @@ } }, "node_modules/expo-local-authentication": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-57.0.0.tgz", - "integrity": "sha512-/ZzS7OD5OaAEhSF/+zPWZqzT8xWy1rMICfDqP5ojJ6Flh4wgAOedG37p8UZlTQYJRR5u1NQ9AoAePV8H/ky14g==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-57.0.2.tgz", + "integrity": "sha512-8K4zcrQ5wZkRS1rwEWY8qHbeGVMNh35e9D1VTVKlMHz1O47itW+pW6iBVuqwGFLozN4fRBeEDQH8hu9Gt58YuQ==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -8777,9 +9253,9 @@ } }, "node_modules/expo-localization": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-57.0.0.tgz", - "integrity": "sha512-6/o4SE4p1K8f0vXrm5oqt7SxyRPLomdhsANsZOwAqduTC/rzkLsqh5FidgppoTR6lPKT58z+jfk5R3grmbjQtA==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-57.0.1.tgz", + "integrity": "sha512-8Ffl4UTbOsQeGT0v5fxMbyPHyPMPnhSPDFQJa8p9rjJrthFoAtNi+fL6Ssmrvf1/7dmPq1mVY52MEt0TMEfgjA==", "license": "MIT", "dependencies": { "rtl-detect": "^1.0.2" @@ -8790,21 +9266,21 @@ } }, "node_modules/expo-mail-composer": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-mail-composer/-/expo-mail-composer-57.0.0.tgz", - "integrity": "sha512-1W3KPWZ6pTEwB5hlJi4tk9omS/kbrESavS140FeT6QA9bR7ZNuNlpbPFiLLE0iqe66f01rxQ35PK8Z08tTGvXw==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-mail-composer/-/expo-mail-composer-57.0.1.tgz", + "integrity": "sha512-6AFey5bULnkE2ZQD20Q31eM8CpoRgkhOcT/4Y08jBHFSxxEy8aAzsbrzc/RpVT42E7I/HSL2JHdfr+HZ9tofrA==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-manifests": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.0.tgz", - "integrity": "sha512-JDKDuF1gd3wPywhu+VzH+qFNDlu91Unqpcre6R9b14MEMq5L3mG6XL9LOySChiWlLJAoObeNPdVV7Od6P++49g==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz", + "integrity": "sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==", "license": "MIT", "dependencies": { - "expo-json-utils": "~57.0.0" + "expo-json-utils": "~57.0.1" }, "peerDependencies": { "expo": "*" @@ -8908,12 +9384,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.3.tgz", - "integrity": "sha512-qPMu2o/xXJFFZY0+gYDspPti6jTRynnqe4Spl1c/XM7e37p2/Bw+Ed8WX7AA5+bMg7H1gIke8P2IzvQiU3UqaA==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz", + "integrity": "sha512-lj2nsAKMMRLXSFnGgaaQrWJ2fdSpLPc/bca6Rkiw4g8zYPn7qX4MRUJgavvzm/hBrzvMnlhXVgJtidOGuwBh+w==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.0", + "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -8953,16 +9429,16 @@ } }, "node_modules/expo-notifications": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.2.tgz", - "integrity": "sha512-8acQPiwmIlmZ+MkieQkaFyJL7/2YpY6b9Rf/afvUA7b3Mxq2TgBYeq5mCv1LJOkuul/H1NdytZ/ExaYXKO/4Fw==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.7.tgz", + "integrity": "sha512-77cqQ1E3B8RQ7FadKSl+bOeSzUfbMhbjMxklqQffXIHx1dILrRELFhq5/UkBW66r+F58KtGEMlw0atPVY2qJyQ==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.11.0", + "@expo/image-utils": "^0.11.4", "abort-controller": "^3.0.0", "badgin": "^1.1.5", - "expo-application": "~57.0.0", - "expo-constants": "~57.0.2" + "expo-application": "~57.0.2", + "expo-constants": "~57.0.7" }, "peerDependencies": { "expo": "*", @@ -8971,15 +9447,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.6.tgz", - "integrity": "sha512-lbLHrs9JRxUcI/UPkFmHoeZqkFeCvGf9ATC6eJOaiEwkS8wQXYUEfHF3nYXHWS601c9xwUFRf6J5PJhqepdRkA==", + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.8.tgz", + "integrity": "sha512-xAyTnZl597G9/r17GOuyTy6VlhjYCVmgzgmP00bhZ9b+VstPl3tTrOOhSFagVpeln47nKp7x7vgkANNheCv4eQ==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.1", - "@expo/metro-runtime": "^57.0.5", + "@expo/metro-runtime": "^57.0.7", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.6", + "@expo/ui": "^57.0.7", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -8999,7 +9475,7 @@ "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", - "react-native-screens": "^4.25.2", + "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", @@ -9008,18 +9484,18 @@ }, "peerDependencies": { "@expo/log-box": "^57.0.1", - "@expo/metro-runtime": "^57.0.5", + "@expo/metro-runtime": "^57.0.7", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.5", - "expo-linking": "^57.0.3", + "expo-constants": "^57.0.7", + "expo-linking": "^57.0.4", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", - "react-native-screens": "^4.25.2", + "react-native-screens": "^4.26.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, @@ -9045,9 +9521,9 @@ } }, "node_modules/expo-secure-store": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.0.tgz", - "integrity": "sha512-vkP16rhW7b4bljW5BC4kKXBpNxQ0O1E9SpI5NIfh2biZnszLTpI/gUF4oBsvOY2nvkh7oXS2ERuUoA8cuS8FWQ==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.1.tgz", + "integrity": "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -9074,13 +9550,13 @@ } }, "node_modules/expo-splash-screen": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.1.tgz", - "integrity": "sha512-fUKsRXp+9VI3velrxSdE7nmF5kVIHqXNd/o+qDS8wlFV/S5DlC49W1USDXh3QEDMJOX6hIcZ21B24hapxSOx6g==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-57.0.5.tgz", + "integrity": "sha512-ZN0LDXlhHRNFjXTYZDojXk8IfaoUIu7qa3hhoBTXgyj1UB/iewGlH6+M3Nvhun2lY2d/+xhwqMhv0hIRoBo09Q==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.1", - "@expo/image-utils": "^0.11.0", + "@expo/config-plugins": "~57.0.6", + "@expo/image-utils": "^0.11.4", "xml2js": "0.6.0" }, "peerDependencies": { @@ -9088,9 +9564,9 @@ } }, "node_modules/expo-status-bar": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.0.tgz", - "integrity": "sha512-wq1fDVAjfrzCj67hOcvEkGpgRrb6xVI7oSA3bfsQ08SFk8il7vGixJq+xyEbtdcrI9CDiNKQrT3ZIjgnyGbbBA==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", + "integrity": "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9121,9 +9597,9 @@ } }, "node_modules/expo-system-ui": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.0.tgz", - "integrity": "sha512-jkGRY0RQsncEhaCPdoiNbRCPAe8q5kFrrMbkiLr7p0oFMMY8LOviSKBpUXLnuwzM1gHC/J531tntrhj+nwpFZA==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.1.tgz", + "integrity": "sha512-r8a6Jk2suL0vI7Uq4iKJab5Eesk8dkB56Q6HksVNkzuAExV0axoikQwZv8aAyHGbu2VHp0artB0N1/PQDLSgBg==", "license": "MIT", "dependencies": { "@react-native/normalize-colors": "0.86.0", @@ -9141,184 +9617,56 @@ } }, "node_modules/expo-updates": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.4.tgz", - "integrity": "sha512-H0Kp9iEKZjJ999KXgTGs2WwSkzG4R/Mm24Jaypapfz70k0+Xy6SF1CN3oeo+qqTOxANrW0zy21cGxYsh+yYTiw==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.10.tgz", + "integrity": "sha512-1j6wIrF7rFAnfiiMxaSfaA6nV5oOeJnAIpquQDpQ70Jd9hKGt9PPDtf0iByiz+AM+PgNbKFpGYuk13sGI+kd1g==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/plist": "^0.8.0", + "@expo/plist": "^0.8.1", "@expo/spawn-async": "^1.8.0", "arg": "^4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", - "expo-eas-client": "~57.0.0", - "expo-manifests": "~57.0.0", + "expo-eas-client": "~57.0.1", + "expo-manifests": "~57.0.1", "expo-structured-headers": "~57.0.0", - "expo-updates-interface": "~57.0.0", + "expo-updates-interface": "~57.0.1", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "nullthrows": "^1.1.1", - "resolve-from": "^5.0.0" - }, - "bin": { - "expo-updates": "bin/cli.js" - }, - "peerDependencies": { - "expo": "*", - "expo-dev-client": "*", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo-dev-client": { - "optional": true - } - } - }, - "node_modules/expo-updates-interface": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.0.tgz", - "integrity": "sha512-AQASxPDpUjHG55R4WXZ5mLu0rE4F2T/JfHOsXLfZPS1A268ynHHFv+MnZ99/Gb0xWvg3ZjNTgXPEWoRCShSJJg==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-updates/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT" - }, - "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.3.tgz", - "integrity": "sha512-py29skFzf5650QPClzGPsozIiok21Z4c1Dp+oCVlV1WcQJ52xKF8KdxhC+NdUOqW2GG0xPbPGvw1kocAGsSgGQ==", - "license": "MIT", - "dependencies": { - "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~57.0.1", - "@expo/config-plugins": "~57.0.1", - "@expo/devcert": "^1.2.1", - "@expo/env": "~2.4.0", - "@expo/image-utils": "^0.11.0", - "@expo/inline-modules": "^0.1.1", - "@expo/json-file": "^11.0.0", - "@expo/log-box": "^57.0.0", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~57.0.2", - "@expo/metro-file-map": "^57.0.0", - "@expo/osascript": "^2.7.0", - "@expo/package-manager": "^1.13.0", - "@expo/plist": "^0.8.0", - "@expo/prebuild-config": "^57.0.3", - "@expo/require-utils": "^57.0.0", - "@expo/router-server": "^57.0.1", - "@expo/schema-utils": "^57.0.0", - "@expo/spawn-async": "^1.8.0", - "@expo/ws-tunnel": "^2.0.0", - "@expo/xcpretty": "^4.4.4", - "@react-native/dev-middleware": "0.86.0", - "accepts": "^1.3.8", - "arg": "^5.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "debug": "^4.3.4", - "dnssd-advertise": "^1.1.4", - "expo-server": "^57.0.0", - "fetch-nodeshim": "^0.4.10", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.2.1", - "multitars": "^1.0.0", - "node-forge": "^1.3.3", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^4.0.4", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "send": "^0.19.0", - "slugify": "^1.3.4", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "terminal-link": "^2.1.1", - "toqr": "^0.1.1", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1", - "zod": "^3.25.76" + "resolve-from": "^5.0.0" }, "bin": { - "expo-internal": "main.js" + "expo-updates": "bin/cli.js" }, "peerDependencies": { "expo": "*", - "expo-router": "*", + "expo-dev-client": "*", + "react": "*", "react-native": "*" }, "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { + "expo-dev-client": { "optional": true } } }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { + "node_modules/expo-updates-interface": { "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.1.tgz", - "integrity": "sha512-jZ+jHG34rRa8HyurTQq5r558dMcb77F3Mt0HMKotAJ8Wm5bJLULJsL3KVLkEVcYP254xI9zzeJVcN79Fck/nLQ==", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.1.tgz", + "integrity": "sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==", "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.2", - "expo": "*", - "expo-constants": "^57.0.2", - "expo-font": "^57.0.0", - "expo-router": "*", - "expo-server": "^57.0.0", - "react": "*", - "react-dom": "*", - "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" - }, - "peerDependenciesMeta": { - "@expo/metro-runtime": { - "optional": true - }, - "expo-router": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - } + "expo": "*" } }, - "node_modules/expo/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } + "node_modules/expo-updates/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" }, "node_modules/expo/node_modules/ansi-styles": { "version": "5.2.0", @@ -9332,45 +9680,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/expo/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/expo/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -9400,66 +9709,6 @@ "node": ">=0.10.0" } }, - "node_modules/expo/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/expo/node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expo/node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/expo/node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/expo/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9782,6 +10031,16 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -9921,6 +10180,31 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -10317,6 +10601,21 @@ "integrity": "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==", "license": "MIT" }, + "node_modules/hermes-estree": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", + "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", + "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.1" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -12612,6 +12911,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12631,6 +12950,39 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -12657,6 +13009,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12724,9 +13086,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -12739,23 +13101,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -12773,9 +13135,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -12793,9 +13155,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -12813,9 +13175,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -12833,9 +13195,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -12853,9 +13215,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -12876,9 +13238,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -12899,9 +13261,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -12922,9 +13284,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -12945,9 +13307,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -12965,9 +13327,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -14534,6 +14896,59 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -15770,9 +16185,9 @@ } }, "node_modules/react-native-screens": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.2.tgz", - "integrity": "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==", + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.26.2.tgz", + "integrity": "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==", "license": "MIT", "dependencies": { "react-freeze": "^1.0.0", @@ -15780,7 +16195,7 @@ }, "peerDependencies": { "react": "*", - "react-native": ">=0.82.0" + "react-native": "*" } }, "node_modules/react-native-svg": { @@ -16732,9 +17147,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -17537,6 +17952,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index c4eb5766..cdd43ae5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "main": "expo-router/entry", "scripts": { "prepare": "node -e \"const {execSync}=require('child_process');try{execSync('git config core.hooksPath scripts/git-hooks',{stdio:'ignore'})}catch(e){}\"", + "postinstall": "patch-package", "start": "expo start", "dev:metro": "node scripts/dev-metro.js", "android": "expo run:android", @@ -24,7 +25,8 @@ "test:unit": "jest --ci --testPathPattern='__tests__/unit'", "test:integration": "jest --ci --testPathPattern='__tests__/integration'", "test:e2e": "jest --ci --testPathPattern='__tests__/e2e'", - "test:e2e:mock": "node e2e/check-sim.js && node e2e/ensure-release-build.js && (MOCK_PORTS=7071,7072 node e2e/mock-server.js & MOCK_PID=$!; sleep 1; maestro test --debug-output e2e/_artifacts/debug e2e/launch.yaml e2e/browse.yaml e2e/bug6_bottom_bar_inset.yaml e2e/pty_turn_divider.yaml e2e/feat1_tree_drill_new_session.yaml e2e/feat2_export_in_info_shelf.yaml e2e/codex_parity.yaml e2e/voice_dictation.yaml e2e/settings_qr_scanner.yaml e2e/feedback_flow.yaml e2e/05_chat_flow.yaml e2e/06_search_anchor.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", + "test:i18n": "jest --ci --testPathPattern='__tests__/i18n'", + "test:e2e:mock": "node e2e/check-sim.js && node e2e/ensure-release-build.js && (MOCK_PORTS=7071,7072 node e2e/mock-server.js & MOCK_PID=$!; sleep 1; maestro test --debug-output e2e/_artifacts/debug e2e/launch.yaml e2e/browse.yaml e2e/session_lifecycle.yaml e2e/server_drag_reorder.yaml e2e/bug6_bottom_bar_inset.yaml e2e/pty_turn_divider.yaml e2e/feat1_tree_drill_new_session.yaml e2e/feat2_export_in_info_shelf.yaml e2e/codex_parity.yaml e2e/voice_dictation.yaml e2e/settings_qr_scanner.yaml e2e/feedback_flow.yaml e2e/05_chat_flow.yaml e2e/06_search_anchor.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", "test:e2e:parallel-fetch": "node e2e/check-sim.js && (MOCK_PORT=7073 MOCK_TOTAL_CONVERSATIONS=30 MOCK_TOTAL_SESSIONS=25 MOCK_PAGE_DELAY_MS=3000 node e2e/pagination-mock-server.js & MOCK_PID=$!; sleep 1; maestro test --debug-output e2e/_artifacts/debug e2e/parallel-fetch-progress.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", "test:e2e:parallel-fetch:non-merged": "node e2e/check-sim.js && (MOCK_PORT=7073 MOCK_TOTAL_CONVERSATIONS=30 MOCK_TOTAL_SESSIONS=25 MOCK_PAGE_DELAY_MS=3000 node e2e/pagination-mock-server.js & MOCK_PID=$!; sleep 1; maestro test --debug-output e2e/_artifacts/debug e2e/non-merged-conv-loading.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", "test:e2e:ts1": "node e2e/check-sim.js && node e2e/ensure-release-build.js && (MOCK_PORTS=7071,7072 node e2e/mock-server.js & MOCK_PID=$!; sleep 1; maestro test --debug-output e2e/_artifacts/debug e2e/ts1_onboarding_pairing.yaml; STATUS=$?; kill $MOCK_PID 2>/dev/null || true; exit $STATUS)", @@ -40,6 +42,7 @@ "lint": "eslint \"**/*.{ts,tsx}\"", "lint:i18n": "eslint ./app ./components ./hooks ./services ./constants ./types --max-warnings=0", "typecheck": "tsc --noEmit", + "check:native-deps": "node scripts/check-native-deps.js", "test:scripts": "node_modules/.bin/jest --config jest.config.scripts.js --ci --forceExit", "dev:list-devices": "./scripts/list-ios-devices.sh", "dev:device": "./scripts/dev-device.sh", @@ -60,7 +63,7 @@ "status:all": "npm run status:ios && npm run status:android" }, "dependencies": { - "@expo/metro-runtime": "~57.0.3", + "@expo/metro-runtime": "~57.0.7", "@gorhom/bottom-sheet": "^5.2.13", "@react-native-async-storage/async-storage": "3.1.1", "@react-native-community/netinfo": "12.0.1", @@ -70,32 +73,32 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-persist-client": "^5.101.1", "clsx": "^2.1.1", - "expo": "^57.0.1", - "expo-blur": "~57.0.0", - "expo-build-properties": "~57.0.2", - "expo-camera": "~57.0.1", - "expo-clipboard": "~57.0.0", - "expo-constants": "~57.0.2", - "expo-dev-client": "~57.0.3", - "expo-device": "~57.0.0", - "expo-document-picker": "~57.0.0", - "expo-font": "~57.0.0", - "expo-haptics": "~57.0.0", - "expo-image-manipulator": "~57.0.1", - "expo-image-picker": "~57.0.1", + "expo": "^57.0.8", + "expo-blur": "~57.0.2", + "expo-build-properties": "~57.0.7", + "expo-camera": "~57.0.3", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.7", + "expo-dev-client": "~57.0.8", + "expo-device": "~57.0.1", + "expo-document-picker": "~57.0.1", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.1", + "expo-image-manipulator": "~57.0.6", + "expo-image-picker": "~57.0.6", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.1", - "expo-local-authentication": "~57.0.0", - "expo-localization": "~57.0.0", - "expo-mail-composer": "^57.0.0", - "expo-notifications": "~57.0.2", - "expo-router": "~57.0.6", - "expo-secure-store": "~57.0.0", + "expo-linking": "~57.0.4", + "expo-local-authentication": "~57.0.2", + "expo-localization": "~57.0.1", + "expo-mail-composer": "~57.0.1", + "expo-notifications": "~57.0.7", + "expo-router": "~57.0.8", + "expo-secure-store": "~57.0.1", "expo-speech-recognition": "56.0.1", - "expo-splash-screen": "~57.0.1", - "expo-status-bar": "~57.0.0", - "expo-system-ui": "~57.0.0", - "expo-updates": "~57.0.4", + "expo-splash-screen": "~57.0.5", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.1", + "expo-updates": "~57.0.9", "i18next": "^26.3.6", "intl-pluralrules": "^2.0.1", "nativewind": "^4.2.6", @@ -113,7 +116,7 @@ "react-native-pager-view": "8.0.2", "react-native-reanimated": "4.5.0", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "4.26.2", "react-native-svg": "15.15.5", "react-native-web": "^0.21.2", "react-native-worklets": "0.10.2", @@ -136,6 +139,7 @@ "expo-mcp": "~0.2.1", "jest": "^29.7.0", "jest-expo": "~57.0.0", + "patch-package": "^8.0.1", "react-refresh": "^0.18.0", "react-test-renderer": "19.2.7", "tailwindcss": "^3.4.10", @@ -152,7 +156,6 @@ }, "overrides": { "uuid": "^11.1.1", - "expo-modules-core": "57.0.7", "minimatch": { "10.2.5": { "brace-expansion": "^5.0.7" diff --git a/patches/expo-modules-jsi+57.0.4.patch b/patches/expo-modules-jsi+57.0.4.patch new file mode 100644 index 00000000..9c8b78ec --- /dev/null +++ b/patches/expo-modules-jsi+57.0.4.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift b/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift +index 1111111..2222222 100644 +--- a/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift ++++ b/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift +@@ -50,7 +50,7 @@ + /// faithful to `new Date(number)`; the `Date`/string branches pass an already-clipped `getTime()` through. + @usableFromInline + func dateFromMilliseconds(_ milliseconds: Double) throws -> Date { +- guard milliseconds.isFinite, abs(milliseconds) <= maxJavaScriptDateMilliseconds else { ++ guard milliseconds.isFinite, Swift.abs(milliseconds) <= maxJavaScriptDateMilliseconds else { + throw InvalidDateException() + } + return Date(timeIntervalSince1970: milliseconds.rounded(.towardZero) / 1000.0) diff --git a/scripts/archive-and-upload.sh b/scripts/archive-and-upload.sh index 689277a9..74479c35 100755 --- a/scripts/archive-and-upload.sh +++ b/scripts/archive-and-upload.sh @@ -59,6 +59,12 @@ if (( EXPORT_OK == 0 )); then exit 70 fi +# Gate the upload on dyld symbol resolution. A pod version skew archives and +# exports cleanly but aborts at launch ("DYLD 4 Symbol missing"), which no +# compile-time check catches — build 173 shipped that way. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$SCRIPT_DIR/verify-dyld-symbols.sh" + # Upload IPA explicitly via altool so we get a real success/failure response. # (method: app-store exports only; xcodebuild's built-in upload with # method: app-store-connect silently drops builds on Apple's ingestion side.) diff --git a/scripts/check-native-deps.js b/scripts/check-native-deps.js new file mode 100644 index 00000000..548c806c --- /dev/null +++ b/scripts/check-native-deps.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +'use strict' +// check-native-deps.js — fail when two versions of one native module are installed. +// +// A native build can only contain one version of a given native module. When npm +// nests a second copy (a transitive dep wanting a newer version than the root +// pin), the pods still compile, but the binaries end up built against mismatched +// Swift signatures and dyld aborts at launch with "Symbol not found". TestFlight +// build 173 shipped exactly that way and crashed on every launch. +// +// `expo-doctor` detects this, but it exits non-zero for unrelated reasons too +// (patch drift, CNG config fields), so it can't gate CI directly. Run it and key +// on just the duplicate-native-module check. + +const { execFileSync } = require('child_process') + +let output +try { + output = execFileSync('npx', ['expo-doctor', '--verbose'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} catch (err) { + // expo-doctor exits non-zero whenever any check fails; we only care about one, + // so read its output rather than trusting the exit code. + output = `${err.stdout || ''}${err.stderr || ''}` +} + +if (!output.trim()) { + console.error('check-native-deps: expo-doctor produced no output') + process.exit(1) +} + +if (!/Check that no duplicate dependencies are installed/.test(output)) { + console.error('check-native-deps: could not find the duplicate-dependency check in expo-doctor output.') + console.error('expo-doctor may have renamed it — update this script.') + process.exit(1) +} + +const failed = /✖ Check that no duplicate dependencies are installed/.test(output) + +if (!failed) { + console.log('✓ no duplicate native modules') + process.exit(0) +} + +// expo-doctor prints the check name twice: once in the pass/fail list, then +// again heading the detail block. The details ("Found duplicates for X: …") +// follow the last occurrence and run until the next check's ✖/✔ heading. +const sections = output.split(/✖ Check that no duplicate dependencies are installed/) +const detail = sections[sections.length - 1] || '' +const report = detail.split(/\n(?=[✖✔])/)[0].trim() + +console.error('✗ duplicate native modules installed\n') +console.error(report || detail.trim()) +console.error('\nA native build may contain only one version of any native module; two copies') +console.error('link against mismatched signatures and crash at launch in dyld.') +console.error('Fix by aligning the root pin in package.json with what the transitive') +console.error('dependency requires (npx expo install --check), then reinstall.') +process.exit(1) diff --git a/scripts/git-hooks/ci-paths.txt b/scripts/git-hooks/ci-paths.txt index 2794078b..fe0b4ebe 100644 --- a/scripts/git-hooks/ci-paths.txt +++ b/scripts/git-hooks/ci-paths.txt @@ -48,3 +48,4 @@ scripts/ fastlane/ eas.json Gemfile +patches/ diff --git a/scripts/verify-dyld-symbols.sh b/scripts/verify-dyld-symbols.sh new file mode 100755 index 00000000..f241a703 --- /dev/null +++ b/scripts/verify-dyld-symbols.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# verify-dyld-symbols.sh — catch "Symbol not found" launch crashes before upload. +# +# A Swift ABI mismatch between two pods (e.g. ExpoCamera built against one +# expo-modules-core protocol signature, a different ExpoModulesCore bundled) +# compiles and archives cleanly, then aborts at launch in dyld with +# EXC_CRASH / "DYLD 4 Symbol missing". TestFlight build 173 shipped that way. +# +# Scope: only symbols that should come from a framework *inside the bundle*. +# Each binary's `otool -L` @rpath entries name its embedded dependencies; a +# Swift symbol mangled with one of those framework's module names must be +# exported by that framework. OS dylib symbols are ignored by construction — +# no denylist to maintain. +# +# Usage: ./scripts/verify-dyld-symbols.sh [path/to/Foo.app] +# Defaults to the .app inside build/Threadbase.xcarchive. +# Exit: 0 all resolved · 1 unresolved symbols found · 2 bad invocation + +set -euo pipefail + +APP_PATH="${1:-}" +if [[ -z "$APP_PATH" ]]; then + ARCHIVE_PATH="${ARCHIVE_PATH:-build/Threadbase.xcarchive}" + APP_PATH="$(find "$ARCHIVE_PATH/Products/Applications" -maxdepth 1 -name '*.app' 2>/dev/null | head -1 || true)" +fi + +if [[ -z "$APP_PATH" || ! -d "$APP_PATH" ]]; then + echo "verify-dyld-symbols: no .app found (looked for '${APP_PATH:-<archive>}')" >&2 + exit 2 +fi + +echo "▸ Verifying dynamic symbols in $(basename "$APP_PATH")" + +APP_NAME="$(basename "$APP_PATH" .app)" +BINARIES=() +[[ -f "$APP_PATH/$APP_NAME" ]] && BINARIES+=("$APP_PATH/$APP_NAME") +while IFS= read -r fw; do + fw_name="$(basename "$fw" .framework)" + [[ -f "$fw/$fw_name" ]] && BINARIES+=("$fw/$fw_name") +done < <(find "$APP_PATH/Frameworks" -maxdepth 1 -name '*.framework' 2>/dev/null | sort) + +if (( ${#BINARIES[@]} == 0 )); then + echo "verify-dyld-symbols: no Mach-O binaries found under $APP_PATH" >&2 + exit 2 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Module names of frameworks actually embedded in the bundle. Swift mangles a +# symbol's defining module into the name as <len><ModuleName>, so these are the +# only prefixes we can attribute to in-bundle code. +MODULES=() +for bin in "${BINARIES[@]}"; do + MODULES+=("$(basename "$bin")") +done + +# Every symbol exported anywhere in the bundle. +for bin in "${BINARIES[@]}"; do + nm -arch arm64 -gU "$bin" 2>/dev/null | awk 'NF>=3 {print $3}' +done | sort -u > "$WORK/defined.txt" + +# Undefined symbols, tagged with the binary that references them. +for bin in "${BINARIES[@]}"; do + nm -arch arm64 -gu "$bin" 2>/dev/null | awk -v b="$(basename "$bin")" 'NF>=1 {print $NF"\t"b}' +done | sort -u > "$WORK/undefined.txt" + +# Keep only undefined symbols whose Swift-mangled module component names a +# framework bundled in this app. `_$s` is the Swift 5+ mangling prefix; the +# module follows as a length-prefixed identifier, so ExpoModulesCore appears +# literally as "15ExpoModulesCore". +: > "$WORK/patterns.txt" +for mod in "${MODULES[@]}"; do + printf '_\$s[0-9]*%s%s\n' "${#mod}" "$mod" >> "$WORK/patterns.txt" +done + +cut -f1 "$WORK/undefined.txt" | sort -u \ + | grep -E "$(paste -sd'|' "$WORK/patterns.txt")" > "$WORK/inbundle.txt" || true + +comm -23 "$WORK/inbundle.txt" "$WORK/defined.txt" > "$WORK/unresolved.txt" || true + +CHECKED=$(wc -l < "$WORK/inbundle.txt" | tr -d ' ') +echo " ${#BINARIES[@]} binaries · $CHECKED in-bundle symbol references checked" + +if [[ ! -s "$WORK/unresolved.txt" ]]; then + echo " ✓ all inter-framework symbols resolve" + exit 0 +fi + +COUNT=$(wc -l < "$WORK/unresolved.txt" | tr -d ' ') +echo >&2 +echo "✗ $COUNT unresolved symbol(s) — this build would crash at launch in dyld:" >&2 +echo >&2 +while IFS= read -r sym; do + needed_by="$(grep -F "$sym"$'\t' "$WORK/undefined.txt" | cut -f2 | sort -u | tr '\n' ' ')" + echo " $sym" >&2 + echo " referenced from: ${needed_by:-<unknown>}" >&2 + demangled="$(xcrun swift-demangle --compact <<<"$sym" 2>/dev/null || true)" + [[ -n "$demangled" && "$demangled" != "$sym" ]] && echo " demangled: $demangled" >&2 +done < "$WORK/unresolved.txt" +echo >&2 +echo "Usually a pod version skew: two pods built against different versions of a" >&2 +echo "shared dependency. Check that the Expo module set in package.json is a" >&2 +echo "coherent SDK release (npx expo install --fix) and regenerate ios/Podfile.lock." >&2 +exit 1 diff --git a/services/api-client.ts b/services/api-client.ts index 45785d32..27db13f7 100644 --- a/services/api-client.ts +++ b/services/api-client.ts @@ -1,13 +1,24 @@ import { useServersStore } from '@/stores/servers' +import { useServerFetchStatusStore } from '@/stores/serverFetchStatus' import { getDeviceClientId } from './device-id' import { clientLog } from '@/lib/clientLog' +import { getServerWarmupState } from './server-warmup' +import type { + CacheAlert, + CacheAlertResolveAction, + ClaudeFlagsConfig, + ClaudeFlagValues, + ServerWarmupState, +} from '@/types/api' export class NetworkError extends Error { code?: string - constructor(message: string, code?: string) { + warmupState?: ServerWarmupState + constructor(message: string, code?: string, warmupState?: ServerWarmupState) { super(message) this.name = 'NetworkError' this.code = code + this.warmupState = warmupState } } @@ -32,11 +43,62 @@ export class SessionNotFoundError extends Error { } } +/** Which signals the server used to decide a conversation is busy. */ +export type ConversationBusyDetectedBy = 'jsonl_mtime' | 'process_argv' | 'process_cwd' + +/** + * Soft 409 from `POST /api/sessions/resume`: the conversation looks like it may + * still be open elsewhere (e.g. an external CLI writing its JSONL). Carries the + * structured payload so callers can name what was detected and offer a + * force-override retry instead of failing with a generic error. + */ +export class ConversationBusyError extends Error { + detectedBy: ConversationBusyDetectedBy[] + lastActivityMs: number | null + likelyOwner: 'external' | 'unknown' + constructor( + message: string, + payload: { + detectedBy?: unknown + lastActivityMs?: unknown + likelyOwner?: unknown + } = {}, + ) { + super(message) + this.name = 'ConversationBusyError' + this.detectedBy = Array.isArray(payload.detectedBy) + ? (payload.detectedBy.filter((d) => typeof d === 'string') as ConversationBusyDetectedBy[]) + : [] + this.lastActivityMs = typeof payload.lastActivityMs === 'number' ? payload.lastActivityMs : null + this.likelyOwner = payload.likelyOwner === 'external' ? 'external' : 'unknown' + } +} + const REQUEST_TIMEOUT_MS = 15000 // First attempt fails over to the silent retry sooner — a stalled connection // shouldn't burn the full 15 s before the retry even starts. const FIRST_ATTEMPT_TIMEOUT_MS = 8000 +function isWarmupFetchEndpoint(method: string, path: string): boolean { + if (method !== 'GET') return false + const pathname = path.split('?')[0] + return pathname === '/api/sessions' || + pathname === '/api/sessions/count' || + pathname === '/api/sessions/recents' || + /^\/api\/sessions\/[^/]+$/.test(pathname) || + pathname === '/api/conversations' || + pathname === '/api/conversations/count' || + /^\/api\/conversations\/[^/]+$/.test(pathname) +} + +function recordWarmupError(serverId: string, errorBody: unknown): ServerWarmupState | undefined { + const warmupState = getServerWarmupState(errorBody) ?? undefined + if (warmupState) { + useServerFetchStatusStore.getState().recordWarmingUp(serverId, warmupState) + } + return warmupState +} + async function request<T>( method: string, path: string, @@ -144,10 +206,15 @@ async function request<T>( if (!response.ok) { let detail = '' let code: string | undefined + let warmupState: ServerWarmupState | undefined + // Hoisted out of the try so the 409 handler below can read the structured + // collision payload, not just the flattened detail/code strings. + let errBody: Record<string, unknown> | undefined try { - const errBody = await response.json() - if (errBody?.error) detail = errBody.error - if (errBody?.code) code = errBody.code + errBody = await response.json() + if (errBody?.error) detail = errBody.error as string + if (errBody?.code) code = errBody.code as string + if (isWarmupFetchEndpoint(method, path)) warmupState = recordWarmupError(serverId, errBody) if (isStartSession) { clientLog.info('startSession', 'start response error body', { status: response.status, @@ -157,10 +224,18 @@ async function request<T>( }) } } catch {} - throw new NetworkError(detail || `Server returned ${response.status}`, code) + // Soft resume-collision: surface the structured payload as a typed error so + // the caller can name what was detected and offer a force-override retry. + if (response.status === 409 && code === 'CONVERSATION_BUSY') { + throw new ConversationBusyError(detail || 'Conversation is busy', errBody ?? {}) + } + throw new NetworkError(detail || `Server returned ${response.status}`, code, warmupState) } const json = (await response.json()) as T + if (isWarmupFetchEndpoint(method, path)) { + useServerFetchStatusStore.getState().recordReady(serverId) + } if (isStartSession) { const keys = json && typeof json === 'object' ? Object.keys(json as object) : [] clientLog.info('startSession', 'start response body', { @@ -275,6 +350,7 @@ async function requestWithMeta<T>( // 304: the cached copy is current. fetch() resolves (does not throw); the // body is empty, so don't call response.json(). if (response.status === 304) { + useServerFetchStatusStore.getState().recordReady(serverId) return { status: 304, etag, body: null } } @@ -283,14 +359,17 @@ async function requestWithMeta<T>( if (!response.ok) { let detail = '' let code: string | undefined + let warmupState: ServerWarmupState | undefined try { const errBody = await response.json() if (errBody?.error) detail = errBody.error if (errBody?.code) code = errBody.code + warmupState = recordWarmupError(serverId, errBody) } catch {} - throw new NetworkError(detail || `Server returned ${response.status}`, code) + throw new NetworkError(detail || `Server returned ${response.status}`, code, warmupState) } + useServerFetchStatusStore.getState().recordReady(serverId) return { status: response.status, etag, body: (await response.json()) as T } } @@ -318,6 +397,86 @@ export interface ResponseWithMeta<T> { body: T | null } +export type ResolveCacheAlertResult = + | { ok: true; action: CacheAlertResolveAction; pruned?: number; backupPath?: string } + | { ok: true; alreadyResolved: true } + | { ok: false; conflict: true; currentFingerprint: string } + +// GET /api/cache/alert. A 404 means the server predates this feature — treat +// as "no alert" rather than an error (see cache-integrity-alert contract). +export async function getCacheAlert(serverId: string): Promise<CacheAlert | null> { + try { + const api = createApiForServer(serverId) + const { pending } = await api.get<{ pending: CacheAlert | null }>('/api/cache/alert') + return pending + } catch (e) { + if (e instanceof NotFoundError) return null + throw e + } +} + +// GET /api/config/claude-flags. A 404 means the server predates this feature — +// return null so the UI hides the section entirely rather than erroring (same +// contract as getCacheAlert above). +export async function getClaudeFlags(serverId: string): Promise<ClaudeFlagsConfig | null> { + try { + const api = createApiForServer(serverId) + return await api.get<ClaudeFlagsConfig>('/api/config/claude-flags') + } catch (e) { + if (e instanceof NotFoundError) return null + throw e + } +} + +// PUT /api/config/claude-flags. Full replace, not a patch: the server has no +// per-key delete semantics, so the client always sends the complete set. +export async function updateClaudeFlags( + serverId: string, + values: ClaudeFlagValues, + extraArgs?: string, +): Promise<ClaudeFlagsConfig> { + const api = createApiForServer(serverId) + return await api.put<ClaudeFlagsConfig>('/api/config/claude-flags', { + values, + ...(extraArgs ? { extraArgs } : {}), + }) +} + +// POST /api/cache/alert/resolve. Uses a direct fetch rather than request<T>() +// because a 409 fingerprint_mismatch is an expected outcome (not an error) and +// request<T>() only surfaces non-ok responses by throwing NetworkError. +export async function resolveCacheAlert( + serverId: string, + body: { fingerprint: string; action: CacheAlertResolveAction; ids?: string[] }, +): Promise<ResolveCacheAlertResult> { + const server = useServersStore.getState().getServer(serverId) + if (!server) throw new NetworkError(`Unknown server: ${serverId}`) + + const url = `${server.url.replace(/\/$/, '')}/api/cache/alert/resolve` + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${server.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + } catch (err) { + throw new NetworkError(`Failed to reach ${url}: ${String(err)}`) + } + + if (response.status === 401) throw new AuthError() + if (response.status === 409) { + const { currentFingerprint } = await response.json() as { currentFingerprint: string } + return { ok: false, conflict: true, currentFingerprint } + } + if (!response.ok) throw new NetworkError(`resolve failed: ${response.status}`) + + return await response.json() as ResolveCacheAlertResult +} + export interface ServerApi { get: <T>(path: string, options?: RequestOptions) => Promise<T> /** Conditional GET exposing status + ETag; `body` is null on 304. */ @@ -325,6 +484,8 @@ export interface ServerApi { /** HTTP QUERY (RFC 10008) — safe/idempotent/cacheable like GET, JSON body like POST. */ query: <T>(path: string, body: unknown, options?: RequestOptions) => Promise<T> post: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<T> + /** Full replace. Used by the server-config endpoints, which have no per-key delete. */ + put: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<T> patch: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<T> delete: <T>(path: string, options?: RequestOptions) => Promise<T> } @@ -335,6 +496,7 @@ export function createApiForServer(serverId: string): ServerApi { getWithMeta: <T>(path: string, options?: RequestOptions) => requestWithMeta<T>(path, serverId, options), query: <T>(path: string, body: unknown, options?: RequestOptions) => request<T>('QUERY', path, body, serverId, options), post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>('POST', path, body, serverId, options), + put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>('PUT', path, body, serverId, options), patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>('PATCH', path, body, serverId, options), delete: <T>(path: string, options?: RequestOptions) => request<T>('DELETE', path, undefined, serverId, options), } @@ -358,6 +520,10 @@ export const api: ServerApi = { const first = useServersStore.getState().activeServerIds[0] return first ? request<T>('POST', path, body, first, options) : Promise.reject(new NetworkError('No servers configured')) }, + put: <T>(path: string, body?: unknown, options?: RequestOptions) => { + const first = useServersStore.getState().activeServerIds[0] + return first ? request<T>('PUT', path, body, first, options) : Promise.reject(new NetworkError('No servers configured')) + }, patch: <T>(path: string, body?: unknown, options?: RequestOptions) => { const first = useServersStore.getState().activeServerIds[0] return first ? request<T>('PATCH', path, body, first, options) : Promise.reject(new NetworkError('No servers configured')) diff --git a/services/backup.ts b/services/backup.ts new file mode 100644 index 00000000..658cb23d --- /dev/null +++ b/services/backup.ts @@ -0,0 +1,114 @@ +import { createApiForServer, NetworkError } from '@/services/api-client' +import { useServersStore } from '@/stores/servers' +import { getDeviceClientId } from '@/services/device-id' +import { + archiveToShareText, + parseBackupArchive, + parseRestoreConflictBody, + parseRestoreResponse, + type BackupArchive, + type RestorePathMapRule, + type RestorePlan, + type RestoreResponse, + type RestoreSummary, +} from '@/types/backup' + +export class BackupParseError extends Error { + constructor() { + super('Server returned an unrecognized backup payload') + this.name = 'BackupParseError' + } +} + +export class RestoreConflictError extends Error { + summary: RestoreSummary + plan: RestorePlan + + constructor(message: string, summary: RestoreSummary, plan: RestorePlan) { + super(message) + this.name = 'RestoreConflictError' + this.summary = summary + this.plan = plan + } +} + +export async function exportBackup( + serverId: string, + signal?: AbortSignal, +): Promise<BackupArchive> { + const api = createApiForServer(serverId) + const body = await api.get<object>('/api/backup/export', { signal }) + const parsed = parseBackupArchive(body) + if (!parsed) throw new BackupParseError() + return parsed +} + +export async function restoreBackup( + serverId: string, + archive: BackupArchive, + options: { apply?: boolean; pathMap?: RestorePathMapRule[] } = {}, +): Promise<RestoreResponse> { + const apply = options.apply === true + if (!apply) { + const api = createApiForServer(serverId) + const body = await api.post<object>('/api/backup/restore', { + archive, + pathMap: options.pathMap, + apply: false, + }) + const parsed = parseRestoreResponse(body) + if (!parsed || parsed.applied !== false) throw new BackupParseError() + return parsed + } + + // Apply path uses a direct fetch so a 409 RESTORE_CONFLICT can carry the plan. + const server = useServersStore.getState().getServer(serverId) + if (!server) throw new NetworkError(`Unknown server: ${serverId}`) + + const url = `${server.url.replace(/\/$/, '')}/api/backup/restore` + const clientId = await getDeviceClientId() + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${server.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Client-Id': clientId, + }, + body: JSON.stringify({ + archive, + pathMap: options.pathMap, + apply: true, + }), + }) + } catch (err) { + throw new NetworkError(`Failed to reach ${url}: ${String(err)}`) + } + + const raw = (await response.json().catch(() => null)) as object | null + if (response.status === 409 && raw) { + const conflict = parseRestoreConflictBody(raw) + if (conflict) { + throw new RestoreConflictError(conflict.message, conflict.summary, conflict.plan) + } + } + if (!response.ok) { + const detail = + raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error?: unknown }).error === 'string' + ? (raw as { error: string }).error + : `Server returned ${response.status}` + const code = + raw && typeof raw === 'object' && 'code' in raw && typeof (raw as { code?: unknown }).code === 'string' + ? (raw as { code: string }).code + : undefined + throw new NetworkError(detail, code) + } + if (!raw) throw new BackupParseError() + const parsed = parseRestoreResponse(raw) + if (!parsed || parsed.applied !== true) throw new BackupParseError() + return parsed +} + +export { archiveToShareText } diff --git a/services/devices.ts b/services/devices.ts new file mode 100644 index 00000000..645ace39 --- /dev/null +++ b/services/devices.ts @@ -0,0 +1,45 @@ +import { createApiForServer } from '@/services/api-client' +import { + parseDeviceRevokeResponse, + parseDevicesResponse, + type DeviceRevokeResponse, + type DevicesResponse, +} from '@/types/devices' + +export class DevicesParseError extends Error { + constructor() { + super('Server returned an unrecognized devices payload') + this.name = 'DevicesParseError' + } +} + +export async function fetchDevices( + serverId: string, + signal?: AbortSignal, +): Promise<DevicesResponse> { + const api = createApiForServer(serverId) + const body = await api.get<object>('/api/devices', { signal }) + const parsed = parseDevicesResponse(body) + if (!parsed) throw new DevicesParseError() + return parsed +} + +export async function revokeDevice( + serverId: string, + deviceId: string, +): Promise<DeviceRevokeResponse> { + const api = createApiForServer(serverId) + const body = await api.post<object>(`/api/devices/${encodeURIComponent(deviceId)}/revoke`) + const parsed = parseDeviceRevokeResponse(body) + if (!parsed) throw new DevicesParseError() + return parsed +} + +export function formatDeviceEpoch(ms: number | null): string { + if (ms == null) return '—' + try { + return new Date(ms).toISOString() + } catch { + return '—' + } +} diff --git a/services/pair-device-name.ts b/services/pair-device-name.ts new file mode 100644 index 00000000..6287cba5 --- /dev/null +++ b/services/pair-device-name.ts @@ -0,0 +1,9 @@ +import { Platform } from 'react-native' +import * as Device from 'expo-device' + +/** Human-readable name sent on `/api/pair/exchange` as `deviceName`. */ +export function defaultPairDeviceName(): string { + const model = Device.modelName?.trim() + if (model) return model.slice(0, 100) + return `Threadbase Mobile (${Platform.OS})`.slice(0, 100) +} diff --git a/services/pair-exchange.ts b/services/pair-exchange.ts index dbc5f75d..845fba27 100644 --- a/services/pair-exchange.ts +++ b/services/pair-exchange.ts @@ -1,5 +1,6 @@ import nacl from 'tweetnacl' import naclUtil from 'tweetnacl-util' +import { parseCapabilityList, type DeviceCapability } from '@/types/devices' export interface PairUri { url: string @@ -12,10 +13,18 @@ export interface ExchangeResult { apiKey: string publicUrl: string | null machineName: string | null + /** Per-device id minted at exchange (C5). Absent on older streamers. */ + deviceId: string | null + /** Scoped credential — store only in SecureStore; never display. */ + deviceToken: string | null + capabilities: DeviceCapability[] | null } export type PairUriErrorCode = 'invalid' | 'expired' | 'bad-server-url' +/** How a pasted manual-entry credential should be resolved. */ +export type PairCredentialKind = 'pair-uri' | 'pair-token' | 'api-key' + export class PairUriError extends Error { readonly code: PairUriErrorCode constructor(code: PairUriErrorCode, message: string) { @@ -25,6 +34,15 @@ export class PairUriError extends Error { } } +/** Classify a pasted token / URI for the manual onboarding pair path. */ +export function classifyPairCredential(raw: string): PairCredentialKind { + const trimmed = raw.trim() + if (trimmed.startsWith('threadbase:')) return 'pair-uri' + // Short-lived tokens from `tb pair` / `/api/pair/start` (`pt_<hex>`). + if (trimmed.startsWith('pt_')) return 'pair-token' + return 'api-key' +} + export class PairExchangeError extends Error { readonly kind: 'network' | 'token' | 'rate-limited' | 'decrypt' | 'server' constructor(kind: PairExchangeError['kind'], message: string) { @@ -83,9 +101,13 @@ function assertNotExpired(exp?: number): void { export async function exchangeToken({ url, token, + deviceName, + readOnly = false, }: { url: string token: string + deviceName?: string + readOnly?: boolean }): Promise<ExchangeResult> { assertHttpServerUrl(url) const trimmedUrl = url.replace(/\/$/, '') @@ -95,12 +117,21 @@ export async function exchangeToken({ const timeoutController = new AbortController() const timeoutId = setTimeout(() => timeoutController.abort(), PAIR_EXCHANGE_TIMEOUT_MS) + const bodyPayload: { + token: string + clientPublicKey: string + deviceName?: string + readOnly?: boolean + } = { token, clientPublicKey } + if (deviceName?.trim()) bodyPayload.deviceName = deviceName.trim().slice(0, 100) + if (readOnly) bodyPayload.readOnly = true + let res: Response try { res = await fetch(`${trimmedUrl}/api/pair/exchange`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, clientPublicKey }), + body: JSON.stringify(bodyPayload), signal: timeoutController.signal, }) } catch (err) { @@ -130,6 +161,9 @@ export async function exchangeToken({ ephemeralPublicKey?: string publicUrl?: string | null machineName?: string | null + deviceId?: string + deviceToken?: string + capabilities?: unknown } | null if (!body?.ciphertext || !body.nonce || !body.ephemeralPublicKey) { @@ -154,11 +188,20 @@ export async function exchangeToken({ const resolvedUrl = body.publicUrl ?? trimmedUrl assertHttpServerUrl(resolvedUrl) + const deviceId = typeof body.deviceId === 'string' ? body.deviceId : null + const deviceToken = typeof body.deviceToken === 'string' ? body.deviceToken : null + const capabilities = Array.isArray(body.capabilities) + ? parseCapabilityList(body.capabilities) + : null + return { url: resolvedUrl, apiKey: naclUtil.encodeUTF8(plain), publicUrl: body.publicUrl ?? null, machineName: body.machineName ?? null, + deviceId, + deviceToken, + capabilities, } } diff --git a/services/provider-health.ts b/services/provider-health.ts new file mode 100644 index 00000000..b2feef35 --- /dev/null +++ b/services/provider-health.ts @@ -0,0 +1,23 @@ +import { createApiForServer } from '@/services/api-client' +import { + parseProvidersResponse, + type ProvidersResponse, +} from '@/types/provider-health' + +export class ProviderHealthParseError extends Error { + constructor() { + super('Server returned an unrecognized providers payload') + this.name = 'ProviderHealthParseError' + } +} + +export async function fetchProviderHealth( + serverId: string, + signal?: AbortSignal, +): Promise<ProvidersResponse> { + const api = createApiForServer(serverId) + const body = await api.get<object>('/api/providers', { signal }) + const parsed = parseProvidersResponse(body) + if (!parsed) throw new ProviderHealthParseError() + return parsed +} diff --git a/services/push-health.ts b/services/push-health.ts new file mode 100644 index 00000000..b9c1729b --- /dev/null +++ b/services/push-health.ts @@ -0,0 +1,29 @@ +import { createApiForServer } from '@/services/api-client' +import { parsePushHealthResponse, type PushHealthResponse } from '@/types/push-health' + +export class PushHealthParseError extends Error { + constructor() { + super('Server returned an unrecognized push health payload') + this.name = 'PushHealthParseError' + } +} + +export async function fetchPushHealth( + serverId: string, + signal?: AbortSignal, +): Promise<PushHealthResponse> { + const api = createApiForServer(serverId) + const body = await api.get<object>('/api/push/health', { signal }) + const parsed = parsePushHealthResponse(body) + if (!parsed) throw new PushHealthParseError() + return parsed +} + +export function formatEpoch(ms: number | null): string { + if (ms == null) return '—' + try { + return new Date(ms).toISOString() + } catch { + return '—' + } +} diff --git a/services/push.ts b/services/push.ts index 8d5aa84f..faa9ab54 100644 --- a/services/push.ts +++ b/services/push.ts @@ -1,6 +1,7 @@ import * as Notifications from 'expo-notifications' import { Platform } from 'react-native' import { createApiForServer } from './api-client' +import { getDeviceClientId } from './device-id' import type { PushRegisterPayload } from '@/types/api' Notifications.setNotificationHandler({ @@ -25,9 +26,13 @@ async function hasPermission(): Promise<boolean> { return granted } -export async function registerPushToken(serverId: string): Promise<void> { +export type RegisterPushResult = + | { ok: true } + | { ok: false; reason: 'permission_denied' | 'token_unavailable' } + +export async function registerPushToken(serverId: string): Promise<RegisterPushResult> { // Never prompt from the registration path — onboarding owns the prompt. - if (!(await hasPermission())) return + if (!(await hasPermission())) return { ok: false, reason: 'permission_denied' } // Only works on physical devices; silently skip on simulators let token: string @@ -35,16 +40,18 @@ export async function registerPushToken(serverId: string): Promise<void> { const result = await Notifications.getExpoPushTokenAsync() token = result.data } catch { - return + return { ok: false, reason: 'token_unavailable' } } const payload: PushRegisterPayload = { token, platform: Platform.OS as 'ios' | 'android', + deviceId: await getDeviceClientId(), } const api = createApiForServer(serverId) await api.post('/api/push/register', payload) + return { ok: true } } /** Register push token with all provided servers. */ diff --git a/services/query-client.ts b/services/query-client.ts index af187c92..22a176d8 100644 --- a/services/query-client.ts +++ b/services/query-client.ts @@ -4,6 +4,8 @@ import { QueryClient, onlineManager, focusManager } from '@tanstack/react-query' import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister' import { AppState, type AppStateStatus } from 'react-native' import { useLoadingStateStore, type QueryCategory } from '@/stores/loading-state' +import { useSessionsStore } from '@/stores/sessions' +import type { MultiConversation, MultiSession } from '@/types/api' const ONE_MINUTE = 1000 * 60 export const QUERY_GC_TIME = ONE_MINUTE * 5 @@ -46,6 +48,67 @@ export const queryClient = new QueryClient({ }, }) +interface ConversationCollection { + conversations: MultiConversation[] + total?: number +} + +interface ConversationInfiniteData { + pages: ConversationCollection[] + pageParams: unknown[] +} + +function withoutServer<T extends { serverId: string }>(items: T[], serverId: string): T[] { + return items.filter((item) => item.serverId !== serverId) +} + +function filterConversationCollection( + collection: ConversationCollection, + serverId: string, +): ConversationCollection { + const conversations = withoutServer(collection.conversations, serverId) + const removed = collection.conversations.length - conversations.length + return { + ...collection, + conversations, + ...(collection.total === undefined + ? {} + : { total: Math.max(0, collection.total - removed) }), + } +} + +/** Drop cached list/detail state belonging to one server while preserving all others. */ +export function clearServerConversationAndSessionState(serverId: string) { + queryClient.setQueriesData<MultiConversation[]>( + { queryKey: ['conversations-eager'] }, + (data) => data ? withoutServer(data, serverId) : data, + ) + queryClient.setQueriesData<MultiConversation[] | ConversationCollection | ConversationInfiniteData>( + { queryKey: ['conversations'] }, + (data) => { + if (!data) return data + if (Array.isArray(data)) return withoutServer(data, serverId) + if ('pages' in data) { + return { ...data, pages: data.pages.map((page) => filterConversationCollection(page, serverId)) } + } + return filterConversationCollection(data, serverId) + }, + ) + queryClient.setQueriesData<MultiSession[]>( + { queryKey: ['sessions-eager'] }, + (data) => data ? withoutServer(data, serverId) : data, + ) + queryClient.setQueriesData<MultiSession[]>( + { queryKey: ['sessions'] }, + (data) => data ? withoutServer(data, serverId) : data, + ) + + queryClient.removeQueries({ queryKey: ['conversation', serverId] }) + queryClient.removeQueries({ queryKey: ['project-conversations', serverId] }) + queryClient.removeQueries({ queryKey: ['session', serverId] }) + useSessionsStore.getState().clearServer(serverId) +} + // React Native has no browser online/offline events, so onlineManager never // learns about radio state on its own — refetchOnReconnect would never fire and // mutations (networkMode 'online') could not detect offline. Drive it from diff --git a/services/sentry.ts b/services/sentry.ts index d85ae344..0b2b81a0 100644 --- a/services/sentry.ts +++ b/services/sentry.ts @@ -445,17 +445,28 @@ async function loadAttachment(attachment: { } /** - * Submit user feedback via Sentry's User Feedback API. Only works when Sentry - * is active (consent on + DSN). The resulting feedback event passes through - * `beforeSend` → `sanitizeFeedbackEvent`, which lets the user-authored message - * and reply email through (they explicitly chose to submit them) while still - * stripping everything else. The screenshot, if provided, is uploaded as a raw - * attachment alongside the feedback event — it bypasses `beforeSend` (which - * only sees the event body) since attachments never touch that hook, but the - * screenshot is already user-picked and stripped of EXIF before it reaches here. + * Submit user feedback via Sentry's User Feedback API. Like `reportOneShot`, + * this treats the explicit user action (tapping "Send feedback") as its own + * consent — it will self-init Sentry for this one submission even if standing + * crash reporting is off, then tear down afterward so the "reporting is off" + * state is genuinely unaffected. This makes "explicit user action always tries + * Sentry first" a consistent rule across the app (crash report button + + * feedback form behave the same way). * - * @returns the feedback event id when submitted, or undefined if Sentry is not - * active (caller should fall back to email/copy). + * The resulting feedback event passes through `beforeSend` → + * `sanitizeFeedbackEvent`, which lets the user-authored message and reply email + * through (they explicitly chose to submit them) while still stripping + * everything else. The screenshot, if provided, is uploaded as a raw attachment + * alongside the feedback event — it bypasses `beforeSend` (which only sees the + * event body) since attachments never touch that hook, but the screenshot is + * already user-picked and stripped of EXIF before it reaches here. + * + * - Still requires a DSN and an environment that permits reporting — those + * gates are unconditional and are not something a UI action can bypass. + * + * @returns the feedback event id when submitted, or undefined if the feedback + * could not be sent (no DSN, environment doesn't permit it, or the + * capture failed). */ export async function submitFeedbackViaSentry(params: { message: string @@ -463,10 +474,17 @@ export async function submitFeedbackViaSentry(params: { category?: string attachment?: { uri: string; mimeType: string; filename?: string } }): Promise<string | undefined> { - if (!initialized) return undefined const message = params.message?.trim() if (!message) return undefined + + const wasAlreadyInitialized = initialized try { + const ready = wasAlreadyInitialized || (await performInit()) + if (!ready) { + if (__DEV__) console.log('[sentry] submitFeedbackViaSentry skipped: could not initialize (no DSN or environment does not permit reporting)') + return undefined + } + const attachments = params.attachment ? [await loadAttachment(params.attachment)] : undefined const id = Sentry.captureFeedback( { @@ -480,9 +498,16 @@ export async function submitFeedbackViaSentry(params: { }, attachments ? { attachments } : undefined, ) + + await Sentry.flush() return typeof id === 'string' ? id : undefined } catch { return undefined + } finally { + if (!wasAlreadyInitialized) { + await disableCrashReporting() + if (__DEV__) console.log('[sentry] submitFeedbackViaSentry: one-shot client closed') + } } } diff --git a/services/server-diagnostics.ts b/services/server-diagnostics.ts new file mode 100644 index 00000000..f65af54e --- /dev/null +++ b/services/server-diagnostics.ts @@ -0,0 +1,64 @@ +import { createApiForServer } from '@/services/api-client' +import { + parseServerDiagnosticsReport, + SERVER_DIAGNOSTICS_CONTRACT_VERSION, + type RemediationCode, + type ServerDiagnosticsReport, +} from '@/types/server-diagnostics' + +export class ServerDiagnosticsParseError extends Error { + constructor() { + super('Server returned an unrecognized diagnostics payload') + this.name = 'ServerDiagnosticsParseError' + } +} + +export async function fetchServerDiagnostics( + serverId: string, + signal?: AbortSignal, +): Promise<ServerDiagnosticsReport> { + const api = createApiForServer(serverId) + const body = await api.get<object>('/api/diagnostics', { signal }) + const report = parseServerDiagnosticsReport(body) + if (!report) throw new ServerDiagnosticsParseError() + return report +} + +export function isSupportedDiagnosticsContract(report: ServerDiagnosticsReport): boolean { + return report.contractVersion === SERVER_DIAGNOSTICS_CONTRACT_VERSION +} + +/** Remediation codes that warrant a user-facing action card (not NONE). */ +export function needsRemediation(code: RemediationCode): boolean { + return code !== 'NONE' +} + +/** + * Sanitized plain-text report for copy/share. Uses only allowlisted fields + * from the contract — never invents server URLs or credentials. + */ +export function serverDiagnosticsToText( + serverLabel: string, + report: ServerDiagnosticsReport, +): string { + const lines: string[] = [ + `Threadbase server health`, + `Server: ${serverLabel}`, + `Contract: v${report.contractVersion}`, + `Generated: ${report.generatedAt}`, + `Overall: ${report.overall}`, + '', + ] + for (const check of report.checks) { + lines.push(`[${check.status}] ${check.id}`) + lines.push(` ${check.summary}`) + lines.push(` remediation: ${check.remediation}`) + if (check.detail) { + for (const [k, v] of Object.entries(check.detail)) { + lines.push(` ${k}: ${v === null ? 'null' : String(v)}`) + } + } + lines.push('') + } + return lines.join('\n').trimEnd() +} diff --git a/services/server-warmup.ts b/services/server-warmup.ts new file mode 100644 index 00000000..a1e985b3 --- /dev/null +++ b/services/server-warmup.ts @@ -0,0 +1,22 @@ +import type { ServerWarmupState } from '@/types/api' + +export const SERVER_WARMING_UP_CODE = 'SERVER_WARMING_UP' + +const WARMUP_STATES: ReadonlySet<string> = new Set([ + 'startup', + 'cache_reset', + 'conversation_refresh', +]) + +export function getServerWarmupState(value: unknown): ServerWarmupState | null { + if (!value || typeof value !== 'object') return null + const candidate = value as { code?: unknown; warmupState?: unknown } + if ( + candidate.code !== SERVER_WARMING_UP_CODE || + typeof candidate.warmupState !== 'string' || + !WARMUP_STATES.has(candidate.warmupState) + ) { + return null + } + return candidate.warmupState as ServerWarmupState +} diff --git a/services/virtual-terminal.ts b/services/virtual-terminal.ts index e759e226..1e0992e5 100644 --- a/services/virtual-terminal.ts +++ b/services/virtual-terminal.ts @@ -4,15 +4,52 @@ * clearing) and maintains a 2D character grid. Produces clean text lines * suitable for display in a non-terminal context. */ +import type { ProviderName } from '@/constants/providers' +import { + getTerminalChromeFilter, + keepTranscriptLine, + type TerminalChromeFilter, +} from '@/lib/terminalChrome' +import { parseConfidenceFromCounters, type ParseConfidence } from '@/lib/renderConfidence' + +// Hard cap on retained rows. The rendered view only ever shows the last +// `terminalMaxLines` (default 5000), so anything older is dead weight — a +// long append-only session would otherwise grow the grid forever and make +// getLines() an O(total-lines) scan on every frame. Kept well above any TUI +// screen height so absolute cursor positioning (H/f) never hits the trim. +const MAX_ROWS = 10_000 + +// CSI finals we intentionally ignore (SGR, modes, reports) without counting +// as unsupported — they are expected noise in agent TUIs. +const IGNORED_CSI = new Set(['m', 'h', 'l', 'n', 't', 'q', 'c', 's', 'u', 'p']) + +const HANDLED_CSI = new Set(['A', 'B', 'C', 'D', 'G', 'H', 'f', 'J', 'K', 'L', 'M', 'S', 'T', 'r']) + export class VirtualTerminal { private grid: string[][] = [[]] private row = 0 private col = 0 /** Holds a trailing ESC that was at the end of a feed() chunk. */ private pendingEsc = false + private chromeFilter: TerminalChromeFilter = getTerminalChromeFilter('claude-code') + private rawMode = false + private unsupportedSequenceCount = 0 + private truncatedEscapeCount = 0 + private bytesFed = 0 + + setProvider(provider?: ProviderName | string | null): void { + this.chromeFilter = getTerminalChromeFilter(provider, { raw: this.rawMode }) + } + + /** When true, skip provider chrome filters and return nearly-raw grid lines. */ + setRawMode(raw: boolean): void { + this.rawMode = raw + this.chromeFilter = getTerminalChromeFilter(null, { raw }) + } /** Feed a chunk of raw terminal data. Can be called incrementally. */ feed(data: string): void { + this.bytesFed += data.length let i = 0 // If previous chunk ended with a bare ESC, prepend it if (this.pendingEsc) { @@ -41,14 +78,12 @@ export class VirtualTerminal { i++ } else if (ch === '\t') { const tabStop = (Math.floor(this.col / 8) + 1) * 8 - // Fill with spaces up to tab stop this.ensureRow(this.row) while (this.col < tabStop) { this.putChar(' ') } i++ } else if (ch.charCodeAt(0) < 32 || ch === '\x7f') { - // Skip other control characters i++ } else { this.ensureRow(this.row) @@ -58,103 +93,39 @@ export class VirtualTerminal { } } - /** Extract visible lines, filtering Claude Code TUI chrome. - * Informed by tweakcc (MIT) knowledge of Claude Code's UI structure: - * - Input border box (round border style with ─ chars) - * - Startup banner / "Clawd" ASCII art (▛███▜) - * - Thinker/spinner symbols (·✢*✳✶✻✽ and braille patterns) - * - Status line (model info, prompt indicator, token counts) - * - Decorative separators (box-drawing characters) - */ - getLines(): string[] { + /** Unfiltered visible lines (empty rows dropped). Used for raw fallback UI. */ + getRawLines(): string[] { return this.grid .map((chars) => chars.join('').trimEnd()) - .filter((line) => { - if (line.length === 0) return false - const trimmed = line.trim() - - // --- Decorative separators --- - // Lines made entirely of box-drawing, block elements, or whitespace - const stripped = line.replace(/[\s=\-─━═│┃┌┐└┘├┤┬┴┼╭╮╯╰╱╲\u2500-\u257F\u2580-\u259F]/g, '') - if (stripped.length === 0) return false - // Banner borders like "╭─ Claude Code v2.1.185 ────" survive the separator - // check because text remains after stripping box chars (incl. spaces) — match - // the compacted form "ClaudeCodev..." as well as the spaced form. - const strippedTrimmed = stripped.trim() - if (/^Claude\s*Code\s*v\d/.test(strippedTrimmed)) return false - if (/^Welcome\s*back/.test(strippedTrimmed)) return false - - // --- Startup banner / Clawd ASCII art --- - if (/[▛▜▙▟███]{3,}/.test(trimmed)) return false - if (/Welcome to Claude Code/.test(trimmed)) return false - // v2.x greeting and version line - if (/^Welcome back\b/.test(trimmed)) return false - if (/^Claude Code\s+v\d/.test(trimmed)) return false - - // --- Thinker / spinner symbols --- - // Claude Code uses: · ✢ * ✳ ✶ ✻ ✽ and braille spinners (from tweakcc defaultSettings) - if (/^[·✢*✳✶✻✽⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏◐◑◒◓\s]+$/.test(trimmed)) return false - // Sautéed/cooking timer lines (thinker format): "✱ Sautéed for 3m 5s" - if (/^[✱✳✶✻✽*·✢]\s+Saut[ée]+d\s+for\s/.test(trimmed)) return false - // Thinking verb + ellipsis: "Thinking…", "Computing…" (180+ verbs from tweakcc) - if (/^[·✢*✳✶✻✽]\s+\w+ing…\s*$/.test(trimmed)) return false - if (/^\w+ing…\s*$/.test(trimmed)) return false - - // --- Status line / prompt chrome --- - // Bare prompt indicator (❯) — chrome - if (/^[❯›>]$/.test(trimmed)) return false - // Prompt + hint chrome: "❯ 0q", "> 2q" - if (/^[❯›>]\s+\d+q$/.test(trimmed)) return false - // Ghost placeholder suggestions: '> Try "how do I …"' - if (/^[❯›>]\s+Try "/.test(trimmed)) return false - // NOTE: '❯ <text>' / '> <text>' lines are kept — that's how Claude Code - // renders the user's submitted message in the transcript. - // Capybara mascot (Bramble) - if (/\(●oo●\)/.test(trimmed) || /\(◐oo◐\)/.test(trimmed)) return false - // Model info line: "Opus 4.6 (1M context) | ~/path..." or compact "Sonnet 4.6 | ~/path time | ⚓N" - if (/^(Opus|Sonnet|Haiku)\s+\d+\.\d+[\s(|]/.test(trimmed)) return false - // "Claude" model variant: "Claude 4.6 Opus..." - if (/^Claude\s+\d+\.\d+\s+(Opus|Sonnet|Haiku)/.test(trimmed)) return false - // Bare pipe fragment: "| ~/Desktop/dev/apps |" - if (/^\|/.test(trimmed)) return false - // Accept edits / update available / mode chrome - if (/^[►▶❯]{1,2}\s*(accept edits|auto|plan)\b/i.test(trimmed)) return false - if (/Update available!/.test(trimmed)) return false - // Run: command status hints - if (/^Run:\s+\S/.test(trimmed)) return false - // Token/cost display: "$0.02 12.3k tokens" - if (/^\$[\d.]+\s+[\d.]+[kmb]?\s+tokens?$/i.test(trimmed)) return false - // Medium/high effort indicators - if (/^[◑◐●]\s*(low|medium|high)\b/i.test(trimmed)) return false - // Hotkey hints - if (/\(shift\+tab to cycle\)/.test(trimmed)) return false - if (/\(ctrl\+o to expand\)/.test(trimmed)) return false - // Compact line expand hints: "... +14 lines (ctrl+o to expand)" - if (/^\.\.\.\s+\+\d+\s+lines\s/.test(trimmed)) return false - // Rate limit indicators (from tweakcc suppressRateLimitOptions) - if (/rate limit/i.test(trimmed) && /\d+\s*(req|request|min)/i.test(trimmed)) return false + .filter((line) => line.length > 0) + } - // --- Agent-status / boot-tip chrome (sub-agent runs, first-run tips) --- - // Backgrounded sub-agent status: "Backgrounded agent Explore (running)", - // "Explore … came to rest", and the transient "Invalid tool parameters" - // banner the orchestrator prints mid-turn. - if (/^Backgrounded agent\b/i.test(trimmed)) return false - if (/\b(came to rest|is running|backgrounded)\b/i.test(trimmed) && /^(Explore|Plan|Task|Agent)\b/.test(trimmed)) return false - if (/^Invalid tool parameters\b/i.test(trimmed)) return false - // First-run boot tips block: "Tips for getting started", "Tip:" lines. - if (/^Tips? for getting started/i.test(trimmed)) return false - // OSC 777 permission notify leaking as text — the tmux DCS passthrough - // (\x1bP…tmux;…\x1b]777;notify;Claude Code;…) isn't recognized by the VT - // escape parser, so its payload renders as a literal line. Drop it; the - // permission gate is surfaced via the structured `permission` event. - if (/]777;notify/.test(trimmed) || /^tmux;\]/.test(trimmed)) return false + /** + * Extract visible lines, applying the active provider chrome filter unless + * raw mode is on. + */ + getLines(): string[] { + return this.getRawLines().filter((line) => keepTranscriptLine(line, this.chromeFilter)) + } - // --- Input border box remnants --- - // Lines that are just the border corners/edges after stripping - if (/^[╭╮╯╰│─┌┐└┘┤├]+$/.test(trimmed)) return false + getParseConfidence(): ParseConfidence { + return parseConfidenceFromCounters({ + unsupportedSequenceCount: this.unsupportedSequenceCount, + truncatedEscapeCount: this.truncatedEscapeCount, + bytesFed: this.bytesFed, + }) + } - return true - }) + getParseStats(): { + unsupportedSequenceCount: number + truncatedEscapeCount: number + bytesFed: number + } { + return { + unsupportedSequenceCount: this.unsupportedSequenceCount, + truncatedEscapeCount: this.truncatedEscapeCount, + bytesFed: this.bytesFed, + } } /** Reset terminal state. */ @@ -163,11 +134,13 @@ export class VirtualTerminal { this.row = 0 this.col = 0 this.pendingEsc = false + this.unsupportedSequenceCount = 0 + this.truncatedEscapeCount = 0 + this.bytesFed = 0 } private putChar(ch: string): void { const line = this.grid[this.row] - // Extend line with spaces if cursor is past the end while (line.length <= this.col) { line.push(' ') } @@ -179,39 +152,52 @@ export class VirtualTerminal { if (i >= data.length) return i if (data[i] === '[') { - // CSI sequence: ESC [ params cmd return this.parseCSI(data, i + 1) } if (data[i] === ']') { - // OSC sequence: ESC ] ... BEL/ST — skip entirely i++ while (i < data.length) { if (data[i] === '\x07') return i + 1 if (data[i] === '\x1b' && i + 1 < data.length && data[i + 1] === '\\') return i + 2 i++ } + // Truncated OSC — count as uncertain + this.truncatedEscapeCount++ return i } + // DCS / SOS / PM / APC — skip until ST when present; otherwise mark unsupported + if (data[i] === 'P' || data[i] === 'X' || data[i] === '^' || data[i] === '_') { + const start = i + i++ + while (i < data.length) { + if (data[i] === '\x1b' && i + 1 < data.length && data[i + 1] === '\\') return i + 2 + if (data[i] === '\x07') return i + 1 + i++ + } + this.unsupportedSequenceCount++ + this.truncatedEscapeCount++ + return start + 1 + } + // Single-character escape (ESC M, ESC 7, ESC 8, etc.) — skip return i + 1 } private parseCSI(data: string, i: number): number { let params = '' - // Collect parameter bytes (ECMA-48 0x30-0x3F): digits, semicolons, and - // private-use prefixes like ? (DEC), > (xterm), < = : etc. while (i < data.length && /[0-9;?>=<:]/.test(data[i])) { params += data[i] i++ } - // Skip intermediate bytes (ECMA-48 0x20-0x2F): space, !, ", #, $, etc. while (i < data.length && data.charCodeAt(i) >= 0x20 && data.charCodeAt(i) <= 0x2f) { i++ } - // The next character is the final byte (command) - if (i >= data.length) return i + if (i >= data.length) { + this.truncatedEscapeCount++ + return i + } const cmd = data[i] i++ @@ -221,86 +207,84 @@ export class VirtualTerminal { private handleCSI(params: string, cmd: string): void { const args = params.split(';').map((s) => parseInt(s, 10) || 0) - // Default to 1 for most commands, but J/K use 0 as a valid mode const n = (cmd === 'J' || cmd === 'K') ? args[0] : (args[0] || 1) + if (!HANDLED_CSI.has(cmd) && !IGNORED_CSI.has(cmd)) { + this.unsupportedSequenceCount++ + return + } + switch (cmd) { - case 'A': // Cursor up + case 'A': this.row = Math.max(0, this.row - n) break - case 'B': // Cursor down + case 'B': this.row += n this.ensureRow(this.row) break - case 'C': // Cursor forward + case 'C': this.col += n break - case 'D': // Cursor back + case 'D': this.col = Math.max(0, this.col - n) break - case 'G': // Cursor horizontal absolute + case 'G': this.col = Math.max(0, n - 1) break - case 'H': // Cursor position (row;col) + case 'H': case 'f': this.row = Math.max(0, (args[0] || 1) - 1) this.col = Math.max(0, (args[1] || 1) - 1) this.ensureRow(this.row) break - case 'J': // Erase in display + case 'J': if (n === 2 || n === 3) { - // Clear entire screen this.grid = [[]] this.row = 0 this.col = 0 } else if (n === 0) { - // Clear from cursor to end of screen if (this.grid[this.row]) { this.grid[this.row].length = this.col } this.grid.length = this.row + 1 } break - case 'K': { // Erase in line + case 'K': { const mode = args[0] || 0 this.ensureRow(this.row) if (mode === 0) { - // Clear from cursor to end of line this.grid[this.row].length = this.col } else if (mode === 1) { - // Clear from start of line to cursor for (let c = 0; c <= this.col && c < this.grid[this.row].length; c++) { this.grid[this.row][c] = ' ' } } else if (mode === 2) { - // Clear entire line this.grid[this.row] = [] } break } - case 'L': // Insert lines + case 'L': this.ensureRow(this.row) for (let j = 0; j < n; j++) { this.grid.splice(this.row, 0, []) } break - case 'M': // Delete lines + case 'M': this.grid.splice(this.row, n) this.ensureRow(this.row) break - case 'S': // Scroll Up — shift content up n lines + case 'S': this.grid.splice(0, Math.min(n, this.grid.length)) this.ensureRow(this.row) break - case 'T': // Scroll Down — insert n blank lines at top + case 'T': for (let j = 0; j < n; j++) { this.grid.unshift([]) } this.row += n break - case 'r': // DECSTBM — Set Scroll Region (ignored, just track) + case 'r': break - // SGR (m), cursor show/hide (h/l), etc. — ignore } } @@ -308,5 +292,10 @@ export class VirtualTerminal { while (this.grid.length <= row) { this.grid.push([]) } + if (this.grid.length > MAX_ROWS) { + const excess = this.grid.length - MAX_ROWS + this.grid.splice(0, excess) + this.row = Math.max(0, this.row - excess) + } } } diff --git a/services/ws-client.ts b/services/ws-client.ts index b377a862..2049e279 100644 --- a/services/ws-client.ts +++ b/services/ws-client.ts @@ -5,6 +5,8 @@ import type { QuestionCancelledWsMessage, PermissionWsMessage, PermissionCancelledWsMessage, + CacheAlertSeverity, + CacheAlertResolveAction, } from '@/types/api' import { getDeviceClientId } from './device-id' import { clientLog } from '@/lib/clientLog' @@ -23,6 +25,16 @@ export type WSMessage = | { type: 'session_ready'; session: Session } | { type: 'cache_ready' } | { type: 'scan_progress'; scanned: number; total: number } + | { + type: 'cache_alert' + fingerprint: string + severity: CacheAlertSeverity + missingCount: number + totalRows: number + detectedAt: string + sample: { id: string; title?: string }[] + } + | { type: 'cache_alert_resolved'; fingerprint: string; action: CacheAlertResolveAction } | { type: 'conversation_event'; sessionId: string; line: string } // Additive batched variant (streamer #202): one frame carries all lines from // a single watcher read. `seqs`, when present, is parallel to `lines` — @@ -30,6 +42,9 @@ export type WSMessage = // Absent for non-claude providers. Old clients ignore this and rely on the // singular conversation_event. | { type: 'conversation_events'; sessionId: string; lines: string[]; seqs?: (number | null)[] } + // External-session liveness ping: the conversation's JSONL grew (or its owner + // changed) without a PTY the streamer owns. Additive; no subscriber wired yet. + | { type: 'conversation_updated'; conversationId: string; messageCount: number; lastActivity: string; ownership: 'external' | 'managed' } | QuestionWsMessage | QuestionCancelledWsMessage | PermissionWsMessage diff --git a/stores/serverFetchStatus.ts b/stores/serverFetchStatus.ts index 95c08bce..62fd1b0b 100644 --- a/stores/serverFetchStatus.ts +++ b/stores/serverFetchStatus.ts @@ -1,16 +1,18 @@ import { create } from 'zustand' +import type { ServerWarmupState } from '@/types/api' // Per-server HTTP fetch health, separate from the WebSocket connection status // in ws-client. A server can be WS-connected but failing GETs (or vice versa). // The Hub header dot and ServerStatusModal AND-combine both signals when // deciding green/amber/red. -export type ServerFetchStatus = 'ok' | 'error' | 'indexing' +export type ServerFetchStatus = 'ok' | 'error' | 'warming_up' export interface ServerFetchStatusEntry { status: ServerFetchStatus /** Human-readable error message when status === 'error'. */ error?: string + warmupState?: ServerWarmupState lastCheckedAt: number } @@ -20,8 +22,9 @@ interface State { interface Actions { recordSuccess: (serverId: string) => void + recordReady: (serverId: string) => void recordFailure: (serverId: string, error: unknown) => void - recordIndexing: (serverId: string) => void + recordWarmingUp: (serverId: string, warmupState: ServerWarmupState) => void reset: () => void } @@ -38,6 +41,15 @@ function describeError(error: unknown): string { export const useServerFetchStatusStore = create<State & Actions>((set) => ({ statuses: {}, recordSuccess: (serverId) => + set((s) => s.statuses[serverId]?.status === 'warming_up' + ? s + : { + statuses: { + ...s.statuses, + [serverId]: { status: 'ok', lastCheckedAt: Date.now() }, + }, + }), + recordReady: (serverId) => set((s) => ({ statuses: { ...s.statuses, @@ -55,11 +67,11 @@ export const useServerFetchStatusStore = create<State & Actions>((set) => ({ }, }, })), - recordIndexing: (serverId) => + recordWarmingUp: (serverId, warmupState) => set((s) => ({ statuses: { ...s.statuses, - [serverId]: { status: 'indexing', lastCheckedAt: Date.now() }, + [serverId]: { status: 'warming_up', warmupState, lastCheckedAt: Date.now() }, }, })), reset: () => set({ statuses: {} }), diff --git a/stores/servers.ts b/stores/servers.ts index f12b9592..fe2b602f 100644 --- a/stores/servers.ts +++ b/stores/servers.ts @@ -1,7 +1,8 @@ import { create } from 'zustand' import * as SecureStore from '@/services/secure-store' -import type { ServerConfig, ServerInfo } from '@/types/api' +import type { CacheAlert, ServerConfig, ServerInfo } from '@/types/api' import { serverIdFromUrl } from '@/types/api' +import type { DeviceCapability } from '@/types/devices' import { pickNextServerColor } from '@/components/sessions/shared/serverPalette' import { recordDiagnosticEvent } from '@/services/diagnostic-events' @@ -15,6 +16,16 @@ function secureKeyForServer(serverId: string): string { return `threadbase_api_key_${serverId}` } +function secureKeyForDeviceToken(serverId: string): string { + return `threadbase_device_token_${serverId}` +} + +export interface AddServerDeviceMeta { + deviceId?: string + deviceToken?: string + capabilities?: DeviceCapability[] +} + /** Minimal shape persisted to AsyncStorage (no secrets). */ interface PersistedServer { id: string @@ -23,6 +34,8 @@ interface PersistedServer { connectionError?: string color?: string symbol?: string + deviceId?: string + deviceCapabilities?: DeviceCapability[] } interface ServersStore { @@ -32,20 +45,26 @@ interface ServersStore { /** Ordered subset of servers visible in sessions/history. */ displayedServerIds: string[] isLoading: boolean - /** Per-server flag: true once the server emits `cache_ready` (scan+index done). */ - cacheReady: Record<string, boolean> /** Per-server scan progress received from `scan_progress` WS events. */ scanProgress: Record<string, { scanned: number; total: number }> /** True once the user has added at least one server (ever). Used to distinguish first launch from "removed all servers". */ hasEverHadServer: boolean - - addServer: (url: string, apiKey: string, label?: string) => Promise<string | { error: 'duplicate' }> + /** Per-server pending cache-integrity alert, or null if none. */ + cacheAlert: Record<string, CacheAlert | null> + + addServer: ( + url: string, + apiKey: string, + label?: string, + device?: AddServerDeviceMeta, + ) => Promise<string | { error: 'duplicate' }> removeServer: (serverId: string) => Promise<void> setDisplayedServerIds: (ids: string[]) => void updateServerLabel: (serverId: string, label: string) => void setConnected: (serverId: string, connected: boolean, info?: ServerInfo) => void - setCacheReady: (serverId: string) => void setScanProgress: (serverId: string, scanned: number, total: number) => void + setCacheAlert: (serverId: string, alert: CacheAlert | null) => void + clearCacheAlert: (serverId: string, fingerprint: string) => void refreshServerInfo: (serverId: string) => Promise<void> editServer: (serverId: string, patch: { url: string; apiKey: string; label?: string }) => Promise<void | { error: 'duplicate' }> loadPersistedServers: () => Promise<void> @@ -78,6 +97,8 @@ async function persistServerList( connectionError: servers[id].connectionError ?? undefined, color: servers[id].color, symbol: servers[id].symbol, + deviceId: servers[id].deviceId, + deviceCapabilities: servers[id].deviceCapabilities, })) const payload = { list, @@ -97,27 +118,14 @@ function toValidUniqueIds(ids: string[], activeServerIds: string[]): string[] { return Array.from(new Set(ids)).filter((id) => activeServerIds.includes(id)) } -// If the server never emits `cache_ready` (timeout, crash, slow scan), dismiss -// the banner after this many milliseconds so it doesn't hang indefinitely. -const CACHE_READY_TIMEOUT_MS = 30_000 -const cacheReadyTimers: Map<string, ReturnType<typeof setTimeout>> = new Map() - -function clearCacheReadyTimer(serverId: string) { - const t = cacheReadyTimers.get(serverId) - if (t !== undefined) { - clearTimeout(t) - cacheReadyTimers.delete(serverId) - } -} - export const useServersStore = create<ServersStore>((set, get) => ({ servers: {}, activeServerIds: [], displayedServerIds: [], isLoading: true, - cacheReady: {}, scanProgress: {}, hasEverHadServer: false, + cacheAlert: {}, get serverUrl() { const { servers, activeServerIds } = get() @@ -133,7 +141,12 @@ export const useServersStore = create<ServersStore>((set, get) => ({ getServer: (serverId: string) => get().servers[serverId], - addServer: async (url: string, apiKey: string, label?: string): Promise<string | { error: 'duplicate' }> => { + addServer: async ( + url: string, + apiKey: string, + label?: string, + device?: AddServerDeviceMeta, + ): Promise<string | { error: 'duplicate' }> => { const normalised = url.replace(/\/+$/, '') // Duplicate check: same normalised URL AND same API key @@ -147,6 +160,9 @@ export const useServersStore = create<ServersStore>((set, get) => ({ const id = serverIdFromUrl(normalised) await SecureStore.setItemAsync(secureKeyForServer(id), apiKey) + if (device?.deviceToken) { + await SecureStore.setItemAsync(secureKeyForDeviceToken(id), device.deviceToken) + } const usedColors = activeServerIds.map((sid) => servers[sid]?.color) const color = pickNextServerColor(usedColors) @@ -160,6 +176,8 @@ export const useServersStore = create<ServersStore>((set, get) => ({ serverInfo: null, connectionError: null, color, + deviceId: device?.deviceId, + deviceCapabilities: device?.capabilities, } set((state) => { @@ -180,6 +198,7 @@ export const useServersStore = create<ServersStore>((set, get) => ({ removeServer: async (serverId: string) => { await SecureStore.deleteItemAsync(secureKeyForServer(serverId)) + await SecureStore.deleteItemAsync(secureKeyForDeviceToken(serverId)) recordDiagnosticEvent('server_removed') set((state) => { @@ -223,50 +242,41 @@ export const useServersStore = create<ServersStore>((set, get) => ({ set((state) => { const server = state.servers[serverId] if (!server) return state - // Reset cacheReady and scanProgress when disconnected so the banner reappears on reconnect. - const cacheReady = connected - ? state.cacheReady - : { ...state.cacheReady, [serverId]: false } + // Reset scan progress when disconnected so stale progress is not reused. const scanProgress = connected ? state.scanProgress : { ...state.scanProgress, [serverId]: { scanned: 0, total: 0 } } - - if (connected) { - // Start a fallback timer: if `cache_ready` never arrives, dismiss the - // banner after the timeout rather than leaving it stuck indefinitely. - clearCacheReadyTimer(serverId) - const timer = setTimeout(() => { - cacheReadyTimers.delete(serverId) - useServersStore.getState().setCacheReady(serverId) - }, CACHE_READY_TIMEOUT_MS) - cacheReadyTimers.set(serverId, timer) - } else { - // Disconnected — cancel any pending timeout; banner resets above. - clearCacheReadyTimer(serverId) - } + // Stale alert state from a disconnected server shouldn't linger in the UI. + const cacheAlert = connected + ? state.cacheAlert + : { ...state.cacheAlert, [serverId]: null } return { servers: { ...state.servers, [serverId]: { ...server, isConnected: connected, serverInfo: info ?? server.serverInfo }, }, - cacheReady, scanProgress, + cacheAlert, } }) }, - setCacheReady: (serverId: string) => { - // Cancel the fallback timeout — the real event arrived first. - clearCacheReadyTimer(serverId) - set((state) => ({ cacheReady: { ...state.cacheReady, [serverId]: true } })) - }, - setScanProgress: (serverId: string, scanned: number, total: number) => set((state) => ({ scanProgress: { ...state.scanProgress, [serverId]: { scanned, total } }, })), + setCacheAlert: (serverId: string, alert: CacheAlert | null) => + set((state) => ({ cacheAlert: { ...state.cacheAlert, [serverId]: alert } })), + + clearCacheAlert: (serverId: string, fingerprint: string) => + set((state) => { + const current = state.cacheAlert[serverId] + if (!current || current.fingerprint !== fingerprint) return state + return { cacheAlert: { ...state.cacheAlert, [serverId]: null } } + }), + refreshServerInfo: async (serverId: string): Promise<void> => { const server = get().servers[serverId] if (!server) return @@ -398,6 +408,8 @@ export const useServersStore = create<ServersStore>((set, get) => ({ connectionError: entry.connectionError ?? null, color, symbol: entry.symbol, + deviceId: entry.deviceId, + deviceCapabilities: entry.deviceCapabilities, } activeServerIds.push(entry.id) } diff --git a/stores/sessions.ts b/stores/sessions.ts index 3ec8d0e3..36efe408 100644 --- a/stores/sessions.ts +++ b/stores/sessions.ts @@ -13,6 +13,7 @@ interface SessionsStore { addToQueue: (serverId: string, sessionId: string, prompt: QueuedPrompt) => void removeFromQueue: (serverId: string, sessionId: string, promptId: string) => void reorderQueue: (serverId: string, sessionId: string, queue: QueuedPrompt[]) => void + clearServer: (serverId: string) => void } export const useSessionsStore = create<SessionsStore>((set) => ({ @@ -49,4 +50,11 @@ export const useSessionsStore = create<SessionsStore>((set) => ({ set((state) => ({ promptQueues: { ...state.promptQueues, [compoundKey(serverId, sessionId)]: queue }, })), + + clearServer: (serverId) => + set((state) => ({ + promptQueues: Object.fromEntries( + Object.entries(state.promptQueues).filter(([key]) => !key.startsWith(`${serverId}::`)), + ), + })), })) diff --git a/types/api.ts b/types/api.ts index 4c6837da..059349d3 100644 --- a/types/api.ts +++ b/types/api.ts @@ -1,4 +1,5 @@ import type { ProviderName } from '@/constants/providers' +import type { DeviceCapability } from '@/types/devices' export type SessionStatus = 'running' | 'waiting_input' | 'idle' @@ -13,6 +14,27 @@ export interface Session { projectName: string branch?: string machineName?: string + /** + * JSONL-derived conversation name (the scanner's slug, or the first user + * message when there is no slug). Set on resumed/historical sessions; absent + * on a freshly-started session with no history yet. Additive; older servers omit it. + */ + sessionName?: string + /** + * Model powering the live session, scraped from Claude's status line + * (e.g. 'Opus 4.8 (1M context)'). Additive; older servers omit it. + */ + model?: string + /** + * Reasoning-effort tier from the status line (e.g. 'high'). Live sessions + * only — absent for historical shapes. Additive; older servers omit it. + */ + effort?: string + /** + * Active permission mode from the status line (e.g. 'accept edits on'). + * Live sessions only. Additive; older servers omit it. + */ + permissionMode?: string lastOutput: string elapsedMs: number promptCount: number @@ -29,6 +51,27 @@ export interface Session { * session.id). Prefer this for REST conversation history when present. */ boundConversationId?: string | null + /** + * OS process id of the underlying CLI. The server sends this for discovered + * external processes; absent for managed PTY sessions and historical shapes. + */ + pid?: number + /** + * Who owns this session's process. `managed` = streamer-owned PTY; + * `external` = a CLI the streamer discovered but does not own; `historical` = + * a resumable shape reconstructed from disk. Additive; older servers omit it. + */ + ownership?: 'managed' | 'external' | 'historical' + /** + * Liveness of the underlying process when the streamer does not own the PTY. + * `unknown` when it can't be determined. Additive; older servers omit it. + */ + processLiveness?: 'alive' | 'gone' | 'unknown' + /** + * Inferred activity from JSONL tailing (not authoritative process status). + * Additive; older servers omit it. + */ + activity?: { state: 'active_writing' | 'quiet'; lastEventAt: string; source: 'jsonl' } } export interface MessageSnapshot { @@ -215,6 +258,58 @@ export interface ServerInfo { machineName: string platform: string activeSessions: number + /** Additive: true when the server serves /api/config/claude-flags. Absent on older servers. */ + claudeFlags?: boolean +} + +// ── Per-server Claude CLI flags ────────────────────────────────────────────── +// The registry is served BY the streamer (only it knows which claude binary is +// installed locally), so the app renders the form generically from this metadata +// rather than hardcoding a flag list that would drift on a CLI upgrade. + +export type ClaudeFlagValueType = 'boolean' | 'string' | 'enum' | 'list' + +/** How risky enabling a flag is. `dangerous` requires an explicit confirmation. */ +export type ClaudeFlagRisk = 'low' | 'elevated' | 'dangerous' + +export interface ClaudeFlagDefinition { + /** Stable key used in requests and for i18n lookup — not the CLI spelling. */ + id: string + flag: string + valueType: ClaudeFlagValueType + enumValues?: string[] + risk: ClaudeFlagRisk +} + +export type ClaudeFlagValue = string | string[] | boolean +export type ClaudeFlagValues = Record<string, ClaudeFlagValue> + +export interface ClaudeFlagsConfig { + registry: ClaudeFlagDefinition[] + values: ClaudeFlagValues + extraArgs: string | null + /** False when the server was started with --claude-flag: changes won't survive a restart. */ + persisted: boolean + warning?: string +} + +/** + * Permission modes that disable the human-in-the-loop confirmation entirely. + * Mirrors DANGEROUS_PERMISSION_MODES in the streamer's src/claude-flags.ts. + */ +export const DANGEROUS_PERMISSION_MODES = ['bypassPermissions', 'dontAsk'] + +/** Effective risk of a specific value — only permissionMode is value-dependent. */ +export function claudeFlagValueRisk( + def: ClaudeFlagDefinition, + value: ClaudeFlagValue, +): ClaudeFlagRisk { + if (def.id === 'permissionMode') { + return typeof value === 'string' && DANGEROUS_PERMISSION_MODES.includes(value) + ? 'dangerous' + : 'low' + } + return def.risk } export interface QueuedPrompt { @@ -245,6 +340,7 @@ export interface NotificationPreferences { export interface PushRegisterPayload { token: string platform: 'ios' | 'android' + deviceId?: string } // ── Browse types ──────────────────────────────────────────────────── @@ -272,8 +368,33 @@ export interface ServerConfig { color?: string /** Optional Phosphor icon name used by the 'symbol' chip variant. */ symbol?: string + /** Paired-device id from `/api/pair/exchange` (C5). Not a secret. */ + deviceId?: string + /** Capability list from pair exchange; absent means legacy owner key (full access). */ + deviceCapabilities?: DeviceCapability[] } +export type CacheAlertSeverity = 'high' | 'low' +export type CacheAlertResolveAction = 'prune_all' | 'prune_selected' | 'ignore' | 'reset_rescan' + +/** + * Pending cache-integrity alert. Same shape as the server's `GET /api/cache/alert` + * response; the WS `cache_alert` broadcast carries a `sample` (first 20) instead + * of the full `missing` list, so store the WS variant with `missing` unset until + * `GET /api/cache/alert` fills it in. + */ +export interface CacheAlert { + fingerprint: string + severity: CacheAlertSeverity + detectedAt: string + missingCount: number + totalRows: number + backupPath?: string + missing?: { id: string; filePath: string; title?: string; tailed: boolean }[] +} + +export type ServerWarmupState = 'startup' | 'cache_reset' | 'conversation_refresh' + export interface MultiSession extends Session { serverId: string serverLabel?: string diff --git a/types/backup.ts b/types/backup.ts new file mode 100644 index 00000000..ff8b633c --- /dev/null +++ b/types/backup.ts @@ -0,0 +1,194 @@ +/** + * Streamer backup/restore contract (C9 / U11). + * Metadata only — conversations live in provider history on disk. + */ + +export interface BackupManifest { + formatVersion: number + createdAt: string + streamerVersion: string + sourceHost: string + includesSecrets: boolean + counts: { projects: number } +} + +export interface BackupProject { + id: string + path: string + name: string | null + createdAt: string + updatedAt: string +} + +export interface BackupArchive { + manifest: BackupManifest + projects: BackupProject[] +} + +export interface RestorePathMapRule { + from: string + to: string +} + +export interface RestorePlan { + create: BackupProject[] + update: BackupProject[] + conflict: { incoming: BackupProject; existingId: string }[] +} + +export interface RestoreSummary { + create: number + update: number + conflict: number +} + +export interface RestoreDryRunResponse { + applied: false + summary: RestoreSummary + plan: RestorePlan +} + +export interface RestoreAppliedResponse { + applied: true + summary: RestoreSummary + appliedCount: number +} + +export type RestoreResponse = RestoreDryRunResponse | RestoreAppliedResponse + +function isRecord(value: object): value is Record<string, string | number | boolean | null | object | object[]> { + return !Array.isArray(value) +} + +function parseProject(raw: object): BackupProject | null { + if (!isRecord(raw)) return null + if (typeof raw.id !== 'string') return null + if (typeof raw.path !== 'string') return null + if (!(raw.name === null || typeof raw.name === 'string')) return null + if (typeof raw.createdAt !== 'string') return null + if (typeof raw.updatedAt !== 'string') return null + return { + id: raw.id, + path: raw.path, + name: raw.name, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + } +} + +function parseManifest(raw: object): BackupManifest | null { + if (!isRecord(raw)) return null + if (typeof raw.formatVersion !== 'number') return null + if (typeof raw.createdAt !== 'string') return null + if (typeof raw.streamerVersion !== 'string') return null + if (typeof raw.sourceHost !== 'string') return null + if (typeof raw.includesSecrets !== 'boolean') return null + if (!raw.counts || typeof raw.counts !== 'object' || Array.isArray(raw.counts)) return null + const counts = raw.counts as Record<string, unknown> + if (typeof counts.projects !== 'number') return null + return { + formatVersion: raw.formatVersion, + createdAt: raw.createdAt, + streamerVersion: raw.streamerVersion, + sourceHost: raw.sourceHost, + includesSecrets: raw.includesSecrets, + counts: { projects: counts.projects }, + } +} + +export function parseBackupArchive(body: object): BackupArchive | null { + if (!isRecord(body)) return null + if (!body.manifest || typeof body.manifest !== 'object' || Array.isArray(body.manifest)) return null + if (!Array.isArray(body.projects)) return null + const manifest = parseManifest(body.manifest) + if (!manifest) return null + const projects: BackupProject[] = [] + for (const item of body.projects) { + if (!item || typeof item !== 'object') return null + const parsed = parseProject(item) + if (!parsed) return null + projects.push(parsed) + } + return { manifest, projects } +} + +function parsePlan(raw: object): RestorePlan | null { + if (!isRecord(raw)) return null + if (!Array.isArray(raw.create) || !Array.isArray(raw.update) || !Array.isArray(raw.conflict)) { + return null + } + const create: BackupProject[] = [] + for (const item of raw.create) { + if (!item || typeof item !== 'object') return null + const p = parseProject(item) + if (!p) return null + create.push(p) + } + const update: BackupProject[] = [] + for (const item of raw.update) { + if (!item || typeof item !== 'object') return null + const p = parseProject(item) + if (!p) return null + update.push(p) + } + const conflict: RestorePlan['conflict'] = [] + for (const item of raw.conflict) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return null + const row = item as Record<string, unknown> + if (!row.incoming || typeof row.incoming !== 'object' || Array.isArray(row.incoming)) return null + if (typeof row.existingId !== 'string') return null + const incoming = parseProject(row.incoming) + if (!incoming) return null + conflict.push({ incoming, existingId: row.existingId }) + } + return { create, update, conflict } +} + +function parseSummary(raw: object): RestoreSummary | null { + if (!isRecord(raw)) return null + if (typeof raw.create !== 'number') return null + if (typeof raw.update !== 'number') return null + if (typeof raw.conflict !== 'number') return null + return { create: raw.create, update: raw.update, conflict: raw.conflict } +} + +export function parseRestoreResponse(body: object): RestoreResponse | null { + if (!isRecord(body)) return null + if (!body.summary || typeof body.summary !== 'object' || Array.isArray(body.summary)) return null + const summary = parseSummary(body.summary) + if (!summary) return null + + if (body.applied === false) { + if (!body.plan || typeof body.plan !== 'object' || Array.isArray(body.plan)) return null + const plan = parsePlan(body.plan) + if (!plan) return null + return { applied: false, summary, plan } + } + + if (body.applied === true) { + if (typeof body.appliedCount !== 'number') return null + return { applied: true, summary, appliedCount: body.appliedCount } + } + + return null +} + +export function parseRestoreConflictBody(body: object): { + summary: RestoreSummary + plan: RestorePlan + message: string +} | null { + if (!isRecord(body)) return null + if (body.code !== 'RESTORE_CONFLICT') return null + if (!body.summary || typeof body.summary !== 'object' || Array.isArray(body.summary)) return null + if (!body.plan || typeof body.plan !== 'object' || Array.isArray(body.plan)) return null + const summary = parseSummary(body.summary) + const plan = parsePlan(body.plan) + if (!summary || !plan) return null + const message = typeof body.error === 'string' ? body.error : 'Restore has unresolved conflicts' + return { summary, plan, message } +} + +export function archiveToShareText(archive: BackupArchive): string { + return JSON.stringify(archive, null, 2) +} diff --git a/types/devices.ts b/types/devices.ts new file mode 100644 index 00000000..1e1a05c6 --- /dev/null +++ b/types/devices.ts @@ -0,0 +1,99 @@ +/** + * Streamer paired-device contract (C5 / U10). + * Source: tb-streamer devices.routes.ts + devices.repository.ts + */ + +export type DeviceCapability = + | 'history:read' + | 'session:control' + | 'fs:browse' + | 'fs:upload' + | 'notifications' + | 'admin' + +export interface DeviceView { + deviceId: string + name: string | null + capabilities: DeviceCapability[] + createdAt: number + lastSeenAt: number | null + revokedAt: number | null +} + +export interface DevicesResponse { + available: boolean + devices: DeviceView[] +} + +export interface DeviceRevokeResponse { + ok: true + alreadyRevoked: boolean +} + +const CAPS: ReadonlySet<string> = new Set([ + 'history:read', + 'session:control', + 'fs:browse', + 'fs:upload', + 'notifications', + 'admin', +]) + +function isRecord(value: object): value is Record<string, string | number | boolean | null | object | object[]> { + return !Array.isArray(value) +} + +export function parseCapabilityList(raw: unknown): DeviceCapability[] { + if (!Array.isArray(raw)) return [] + const out: DeviceCapability[] = [] + for (const item of raw) { + if (typeof item === 'string' && CAPS.has(item)) out.push(item as DeviceCapability) + } + return out +} + +function parseDevice(raw: object): DeviceView | null { + if (!isRecord(raw)) return null + if (typeof raw.deviceId !== 'string') return null + if (!(raw.name === null || typeof raw.name === 'string')) return null + if (!Array.isArray(raw.capabilities)) return null + if (typeof raw.createdAt !== 'number') return null + if (!(raw.lastSeenAt === null || typeof raw.lastSeenAt === 'number')) return null + if (!(raw.revokedAt === null || typeof raw.revokedAt === 'number')) return null + return { + deviceId: raw.deviceId, + name: raw.name, + capabilities: parseCapabilityList(raw.capabilities), + createdAt: raw.createdAt, + lastSeenAt: raw.lastSeenAt, + revokedAt: raw.revokedAt, + } +} + +export function parseDevicesResponse(body: object): DevicesResponse | null { + if (!isRecord(body)) return null + if (typeof body.available !== 'boolean') return null + if (!Array.isArray(body.devices)) return null + const devices: DeviceView[] = [] + for (const item of body.devices) { + if (!item || typeof item !== 'object') continue + const parsed = parseDevice(item) + if (parsed) devices.push(parsed) + } + return { available: body.available, devices } +} + +export function parseDeviceRevokeResponse(body: object): DeviceRevokeResponse | null { + if (!isRecord(body)) return null + if (body.ok !== true) return null + if (typeof body.alreadyRevoked !== 'boolean') return null + return { ok: true, alreadyRevoked: body.alreadyRevoked } +} + +export function deviceHasCapability( + capabilities: DeviceCapability[] | undefined, + cap: DeviceCapability, +): boolean { + if (!capabilities || capabilities.length === 0) return true // unknown/legacy = full owner key + return capabilities.includes(cap) || capabilities.includes('admin') +} diff --git a/types/provider-health.ts b/types/provider-health.ts new file mode 100644 index 00000000..199d28ae --- /dev/null +++ b/types/provider-health.ts @@ -0,0 +1,166 @@ +/** + * Streamer provider health contract (C2) — mirrors + * `src/services/providers/providerHealth.ts` + capabilities.ts. + */ + +import { CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type ProviderName } from '@/constants/providers' + +export type FreshSessionIdMode = 'explicit' | 'late-bound' +export type ResumeMode = 'native' | 'unsupported' +export type SystemPromptMode = 'flag' | 'positional' | 'unsupported' + +export interface ProviderCapabilities { + freshSessionId: FreshSessionIdMode + resume: ResumeMode + systemPrompt: SystemPromptMode + structuredQuestions: boolean + permissionGates: boolean + liveControl: boolean +} + +export interface VerifiedAgainst { + min?: string + max?: string + captured: string[] +} + +export type ProviderWarningCode = + | 'provider_not_found' + | 'version_undetectable' + | 'version_unverified' + +export interface ProviderWarning { + code: ProviderWarningCode + message: string +} + +export interface ProviderHealth { + name: ProviderName + available: boolean + version: string | null + verifiedAgainst: VerifiedAgainst + capabilities: ProviderCapabilities + warnings: ProviderWarning[] +} + +export interface ProvidersResponse { + providers: ProviderHealth[] +} + +/** Generic-terminal fallback when the streamer does not list a provider. */ +export const GENERIC_TERMINAL_CAPABILITIES: ProviderCapabilities = { + freshSessionId: 'late-bound', + resume: 'unsupported', + systemPrompt: 'unsupported', + structuredQuestions: false, + permissionGates: false, + liveControl: true, +} + +const FRESH: ReadonlySet<string> = new Set(['explicit', 'late-bound']) +const RESUME: ReadonlySet<string> = new Set(['native', 'unsupported']) +const SYSTEM: ReadonlySet<string> = new Set(['flag', 'positional', 'unsupported']) +const WARN: ReadonlySet<string> = new Set([ + 'provider_not_found', + 'version_undetectable', + 'version_unverified', +]) +const NAMES: ReadonlySet<string> = new Set([CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER]) + +function isRecord(value: object): value is Record<string, string | number | boolean | null | object | object[]> { + return !Array.isArray(value) +} + +function parseCapabilities(raw: object): ProviderCapabilities | null { + if (!isRecord(raw)) return null + const { + freshSessionId, + resume, + systemPrompt, + structuredQuestions, + permissionGates, + liveControl, + } = raw + if (typeof freshSessionId !== 'string' || !FRESH.has(freshSessionId)) return null + if (typeof resume !== 'string' || !RESUME.has(resume)) return null + if (typeof systemPrompt !== 'string' || !SYSTEM.has(systemPrompt)) return null + if (typeof structuredQuestions !== 'boolean') return null + if (typeof permissionGates !== 'boolean') return null + if (typeof liveControl !== 'boolean') return null + return { + freshSessionId: freshSessionId as FreshSessionIdMode, + resume: resume as ResumeMode, + systemPrompt: systemPrompt as SystemPromptMode, + structuredQuestions, + permissionGates, + liveControl, + } +} + +function parseVerified(raw: object): VerifiedAgainst | null { + if (!isRecord(raw)) return null + const capturedRaw = raw.captured + if (!Array.isArray(capturedRaw)) return null + const captured = capturedRaw.filter((v): v is string => typeof v === 'string') + const out: VerifiedAgainst = { captured } + if (typeof raw.min === 'string') out.min = raw.min + if (typeof raw.max === 'string') out.max = raw.max + return out +} + +function parseWarning(raw: object): ProviderWarning | null { + if (!isRecord(raw)) return null + if (typeof raw.code !== 'string' || !WARN.has(raw.code)) return null + if (typeof raw.message !== 'string') return null + return { code: raw.code as ProviderWarningCode, message: raw.message } +} + +function parseProvider(raw: object): ProviderHealth | null { + if (!isRecord(raw)) return null + if (typeof raw.name !== 'string' || !NAMES.has(raw.name)) return null + if (typeof raw.available !== 'boolean') return null + if (!(raw.version === null || typeof raw.version === 'string')) return null + if (!raw.verifiedAgainst || typeof raw.verifiedAgainst !== 'object' || Array.isArray(raw.verifiedAgainst)) { + return null + } + if (!raw.capabilities || typeof raw.capabilities !== 'object' || Array.isArray(raw.capabilities)) { + return null + } + const verifiedAgainst = parseVerified(raw.verifiedAgainst) + const capabilities = parseCapabilities(raw.capabilities) + if (!verifiedAgainst || !capabilities) return null + const warningsRaw = Array.isArray(raw.warnings) ? raw.warnings : [] + const warnings: ProviderWarning[] = [] + for (const w of warningsRaw) { + if (!w || typeof w !== 'object') continue + const parsed = parseWarning(w) + if (parsed) warnings.push(parsed) + } + return { + name: raw.name as ProviderName, + available: raw.available, + version: raw.version, + verifiedAgainst, + capabilities, + warnings, + } +} + +export function parseProvidersResponse(body: object): ProvidersResponse | null { + if (!isRecord(body)) return null + if (!Array.isArray(body.providers)) return null + const providers: ProviderHealth[] = [] + for (const item of body.providers) { + if (!item || typeof item !== 'object') continue + const parsed = parseProvider(item) + if (parsed) providers.push(parsed) + } + return { providers } +} + +export function findProviderHealth( + list: ProviderHealth[] | undefined, + name: ProviderName, +): ProviderHealth | undefined { + return list?.find((p) => p.name === name) +} diff --git a/types/push-health.ts b/types/push-health.ts new file mode 100644 index 00000000..cd169042 --- /dev/null +++ b/types/push-health.ts @@ -0,0 +1,74 @@ +/** Streamer push health contract (C7) — GET /api/push/health */ + +export type PushTokenState = + | 'never-delivered' + | 'healthy' + | 'failing' + | 'dead' + | 'revoked' + +export interface PushTokenHealth { + platform: string + deviceId: string | null + registeredAt: number + lastSuccessAt: number | null + lastFailureAt: number | null + lastFailureCode: string | null + failureStreak: number + revokedAt: number | null + state: PushTokenState +} + +export interface PushHealthResponse { + available: boolean + tokens: PushTokenHealth[] +} + +const STATES: ReadonlySet<string> = new Set([ + 'never-delivered', + 'healthy', + 'failing', + 'dead', + 'revoked', +]) + +function isRecord(value: object): value is Record<string, string | number | boolean | null | object | object[]> { + return !Array.isArray(value) +} + +function parseToken(raw: object): PushTokenHealth | null { + if (!isRecord(raw)) return null + if (typeof raw.platform !== 'string') return null + if (!(raw.deviceId === null || typeof raw.deviceId === 'string')) return null + if (typeof raw.registeredAt !== 'number') return null + if (!(raw.lastSuccessAt === null || typeof raw.lastSuccessAt === 'number')) return null + if (!(raw.lastFailureAt === null || typeof raw.lastFailureAt === 'number')) return null + if (!(raw.lastFailureCode === null || typeof raw.lastFailureCode === 'string')) return null + if (typeof raw.failureStreak !== 'number') return null + if (!(raw.revokedAt === null || typeof raw.revokedAt === 'number')) return null + if (typeof raw.state !== 'string' || !STATES.has(raw.state)) return null + return { + platform: raw.platform, + deviceId: raw.deviceId, + registeredAt: raw.registeredAt, + lastSuccessAt: raw.lastSuccessAt, + lastFailureAt: raw.lastFailureAt, + lastFailureCode: raw.lastFailureCode, + failureStreak: raw.failureStreak, + revokedAt: raw.revokedAt, + state: raw.state as PushTokenState, + } +} + +export function parsePushHealthResponse(body: object): PushHealthResponse | null { + if (!isRecord(body)) return null + if (typeof body.available !== 'boolean') return null + if (!Array.isArray(body.tokens)) return null + const tokens: PushTokenHealth[] = [] + for (const item of body.tokens) { + if (!item || typeof item !== 'object') continue + const parsed = parseToken(item) + if (parsed) tokens.push(parsed) + } + return { available: body.available, tokens } +} diff --git a/types/server-diagnostics.ts b/types/server-diagnostics.ts new file mode 100644 index 00000000..17c17e5f --- /dev/null +++ b/types/server-diagnostics.ts @@ -0,0 +1,111 @@ +/** + * Streamer diagnostics contract (C6) — mirrors tb-streamer + * `src/services/diagnostics/diagnostics.ts` contractVersion 1. + * Field names must stay in lockstep with the HTTP response. + */ + +export const SERVER_DIAGNOSTICS_CONTRACT_VERSION = 1 + +export type CheckStatus = 'ok' | 'degraded' | 'failed' | 'unknown' + +export type RemediationCode = + | 'PROVIDER_NOT_INSTALLED' + | 'PROVIDER_VERSION_UNVERIFIED' + | 'DB_UNAVAILABLE' + | 'DB_MIGRATION_PENDING' + | 'PTY_UNAVAILABLE' + | 'CACHE_DEGRADED' + | 'CLOCK_SKEWED' + | 'FS_SCOPE_MISSING' + | 'NONE' + +export interface ServerDiagnosticCheck { + id: string + status: CheckStatus + summary: string + remediation: RemediationCode + detail?: Record<string, string | number | boolean | null> +} + +export interface ServerDiagnosticsReport { + contractVersion: number + generatedAt: string + overall: CheckStatus + checks: ServerDiagnosticCheck[] +} + +const CHECK_STATUSES: ReadonlySet<string> = new Set(['ok', 'degraded', 'failed', 'unknown']) + +const REMEDIATION_CODES: ReadonlySet<string> = new Set([ + 'PROVIDER_NOT_INSTALLED', + 'PROVIDER_VERSION_UNVERIFIED', + 'DB_UNAVAILABLE', + 'DB_MIGRATION_PENDING', + 'PTY_UNAVAILABLE', + 'CACHE_DEGRADED', + 'CLOCK_SKEWED', + 'FS_SCOPE_MISSING', + 'NONE', +]) + +function isRecord(value: object): value is Record<string, string | number | boolean | null | object | object[]> { + return !Array.isArray(value) +} + +function parseDetail( + value: string | number | boolean | null | object | object[] | undefined, +): Record<string, string | number | boolean | null> | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const out: Record<string, string | number | boolean | null> = {} + for (const [k, v] of Object.entries(value)) { + if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + out[k] = v + } + } + return Object.keys(out).length > 0 ? out : undefined +} + +function parseCheck(raw: object): ServerDiagnosticCheck | null { + if (!isRecord(raw)) return null + const id = raw.id + const status = raw.status + const summary = raw.summary + const remediation = raw.remediation + if (typeof id !== 'string' || typeof summary !== 'string') return null + if (typeof status !== 'string' || !CHECK_STATUSES.has(status)) return null + if (typeof remediation !== 'string' || !REMEDIATION_CODES.has(remediation)) return null + return { + id, + status: status as CheckStatus, + summary, + remediation: remediation as RemediationCode, + detail: parseDetail(raw.detail), + } +} + +/** Narrow a JSON body to the diagnostics report, or null if unusable. */ +export function parseServerDiagnosticsReport(body: object): ServerDiagnosticsReport | null { + if (!isRecord(body)) return null + const contractVersion = body.contractVersion + const generatedAt = body.generatedAt + const overall = body.overall + const checksRaw = body.checks + if (typeof contractVersion !== 'number') return null + if (typeof generatedAt !== 'string') return null + if (typeof overall !== 'string' || !CHECK_STATUSES.has(overall)) return null + if (!Array.isArray(checksRaw)) return null + + const checks: ServerDiagnosticCheck[] = [] + for (const item of checksRaw) { + if (!item || typeof item !== 'object') continue + const parsed = parseCheck(item) + if (parsed) checks.push(parsed) + } + + return { + contractVersion, + generatedAt, + overall: overall as CheckStatus, + checks, + } +} diff --git a/utils/confirmDangerousChange.ts b/utils/confirmDangerousChange.ts new file mode 100644 index 00000000..b4ea4d00 --- /dev/null +++ b/utils/confirmDangerousChange.ts @@ -0,0 +1,65 @@ +import { Alert } from 'react-native' +import * as LocalAuthentication from 'expo-local-authentication' +import i18n from '@/lib/i18n' + +/** + * Confirm a change that disables Claude's permission prompts. + * + * Two tiers, by design: + * - Biometrics enrolled → the OS prompt (Face/Touch ID or device passcode) + * gates the change, so someone holding an unlocked phone still can't flip it. + * - Not enrolled → a plain Approve/Cancel alert. Weaker, but the only + * thing available; the point is that it can never happen without an + * explicit, informed tap. + * + * Returns true only on an affirmative confirmation. Any error, cancel or + * dismissal returns false — this must fail closed. + */ +export async function confirmDangerousChange(message: string): Promise<boolean> { + const title = i18n.t('servers:claudeFlags.confirmTitle') + + let enrolled = false + try { + enrolled = await LocalAuthentication.isEnrolledAsync() + } catch { + // Treat a failing biometric stack as "not enrolled" and fall through to the + // alert, rather than blocking the user out of the setting entirely. + enrolled = false + } + + if (enrolled) { + const acknowledged = await new Promise<boolean>((resolve) => { + Alert.alert(title, message, [ + { text: i18n.t('common:button.cancel'), style: 'cancel', onPress: () => resolve(false) }, + { + text: i18n.t('servers:claudeFlags.confirmContinue'), + style: 'destructive', + onPress: () => resolve(true), + }, + ]) + }) + if (!acknowledged) return false + + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: title, + cancelLabel: i18n.t('common:button.cancel'), + disableDeviceFallback: false, + }) + return result.success + } catch { + return false + } + } + + return await new Promise<boolean>((resolve) => { + Alert.alert(title, message, [ + { text: i18n.t('common:button.cancel'), style: 'cancel', onPress: () => resolve(false) }, + { + text: i18n.t('servers:claudeFlags.confirmApprove'), + style: 'destructive', + onPress: () => resolve(true), + }, + ]) + }) +} diff --git a/utils/mergeLiveMessages.ts b/utils/mergeLiveMessages.ts new file mode 100644 index 00000000..8bb1ba8f --- /dev/null +++ b/utils/mergeLiveMessages.ts @@ -0,0 +1,33 @@ +import type { Message } from '@/types/api' + +/** + * Merge live (WebSocket) messages onto REST history, deduping by uuid then id. + * + * uuid dedup (first pass): a live message whose uuid already appears in history + * is dropped. ids never match across REST/WS — REST uses index-based ids + * (`<conv>-<index>`), WS uses the message uuid/timestamp — so a line that + * arrives over WS and again via the REST drain would otherwise appear twice. + * History always carries the authoritative uuid, so the WS copy loses. + * + * id dedup (second pass): guards uuid-less messages, whose fallback + * timestamp-type-role ids can still collide across the boundary. A duplicate + * FlashList key triggers a render loop. + * + * `middle` is spliced between history and live (the live-session view's + * optimistic user bubbles); read-only callers omit it. Order is always + * history → middle → live — live never re-sorts by timestamp/index. + */ +export function mergeLiveMessages( + orderedHistorical: Message[], + liveMessages: Message[], + middle: Message[] = [], +): Message[] { + const seenUuids = new Set(orderedHistorical.map((m) => m.uuid).filter(Boolean)) + const newLive = liveMessages.filter((m) => !m.uuid || !seenUuids.has(m.uuid)) + const seen = new Set<string>() + return [...orderedHistorical, ...middle, ...newLive].filter((m) => { + if (seen.has(m.id)) return false + seen.add(m.id) + return true + }) +}