Commits
Two safeguards on bot file writes:
1. edit_file (surgical): replace an exact old_string with new_string,
matched against current content. A non-matching or ambiguous match is
rejected without changing the file, and untouched regions — including a
human's concurrent edit elsewhere — are preserved. Preferred over
update_file for changes.
2. update_file (whole-file rewrite) now uses optimistic concurrency: the
bot must read_file this turn AND the content must be unchanged since, or
the write is rejected and the bot is told to re-read — so a concurrent
human edit can't be silently clobbered. A per-invocation read-snapshot
map (seeded by read_file/create_file and refreshed after each write)
backs the check.
Refactor: the tool set moved to a standalone buildFileTools(deps) so the
write-safety logic is unit-testable against a fake store; ProjectStore
delegates to it. System preamble updated to steer bots to edit_file.
Verified the real buildFileTools end to end (12 cases): surgical
replace/non-match/ambiguous/replace_all, must-read-first, stale-rejection,
applies-when-fresh, and surgical-edit-preserves-concurrent-human-change.
Bots were chat-only — the model got just the conversation turns and no
tools. Now invokeBot hands every bot a file tool set (AI SDK tool-calling)
and a system preamble listing the project's files:
- list_files — names + text/binary
- read_file — full text contents by name
- create_file — new text file (rejects an existing name)
- update_file — replace an existing file's text
Each tool maps to an existing store mutation, so a bot's writes persist
and broadcast to peers exactly like a human's; mutations are attributed to
the user running the bot. stopWhen: stepCountIs(8) allows read→edit→reply
loops (without it the model would call a tool and never reply). Tools
return human-readable strings, including soft errors, so the model can
recover rather than the run throwing. Binary (PDF) files are listed but
not text-readable/editable.
Verified the tool-loop wiring with a mock model (create → update →
closing text across steps; executes mutate state; stopWhen continues).
Live tool-calling depends on a real provider key + the model choosing to
call tools.
A "+" button on the project sidebar's Members header (owner-only) opens a
picker of people you've collaborated with before who aren't already in
this project. Selecting one adds them directly — unwrap our project key,
re-seal it to their identity_pub, POST the member row — the same offline
direct-add path as the project-list picker, no invite link or message
mention needed. The member list refreshes on success.
Wiring: ProjectView loads the known-collaborators list + warms the name
cache and owns addMember (it has prf + the wrapped key); SidebarContent
renders the +/popup and filters out current members. SidebarHeader gained
an optional right-side action slot.
Note: the in-editor @mention list already sources only from project
members (since the earlier change), so there's no accidental-add path from
mentioning — adding a member is now an explicit, separate action.
Verified in a headless browser (mocked collaborator): the + shows on
Members, the popup lists the collaborator, and selecting fires the
add-member POST with a peer-sealed key.
Multiparty conversations: a "↩ reply" button under every message from
someone else (bots and other members) prefills the composer with that
author's @mention, so you can follow up without remembering to type it.
Shift+R does the same for the most recent message from someone else when
focus isn't in a text field — and that message's reply link advertises
the shortcut as "↩ reply (⇧R)" so it's discoverable.
Implementation: Composer exposes an imperative prefillReply(mention) (via
forwardRef) that prepends the mention and focuses — keeping the composer's
text state local so per-keystroke typing doesn't re-render the turn list.
ConversationPane owns the reply target + the window keydown handler; reply
buttons skip your own messages (no self-mention). Bots resolve to their
name, people to their display-name handle.
Verified in a headless browser: reply button appears on a bot turn with
the ⇧R hint, and both clicking it and pressing Shift+R prefill "@claude ".
You can now @-mention from the edit view, not just the composer — handy
when you forgot to address the bot. On save, any bot mentioned now but
not before the edit is invoked (only the newly-added ones, so a bot that
already replied isn't pinged again). Missing-key / invoke errors surface
inline under the message; a "Sending to bot…" note shows while it runs.
Refactor: the typeahead (popover + mention state + insert) is extracted
into a reusable MentionInput used by both the composer and the edit
textarea. onSubmit fires on ⌘/Ctrl+Enter, onCancel on Escape (when the
popover is closed). No behavior change to the composer.
Verified in a headless browser: edit view shows the typeahead, accepting
inserts the handle, the edited message renders the highlighted mention,
and saving with a newly-added @bot fires the invoke.
Keep listing other members + bots; just drop self from the suggestion
list (classification still recognizes your own handle if typed).
Mention candidates were built from doc.profiles (users who'd set a name)
minus self — so a collaborator who hadn't named themselves never appeared,
and a solo/own-name project showed no users at all (only bots).
Source candidates from the authoritative member list instead, resolving
each name via displayNameFor (falling back to the short id), and include
self (labelled "you"). buildMentionSets now also classifies every member's
resolved handle as a user mention, so a typed @id renders violet and
upgrades automatically once that member picks a real name. Bots still win
a handle collision.
Verified in-app: typing @ lists the bot (blue) and the member (violet,
"you"); covered the candidate/classify logic with the earlier unit test.
The composer's mention autocomplete now offers human members alongside
bots. Selecting a member inserts their handle (normalized from their
display name, the same form the renderer classifies), so it lights up
violet; bots stay blue. Matching is on either the handle or the visible
label, so "@di" finds a member named "Dietrich".
- buildMentionCandidates: bots + named members from the CRDT profiles,
deduped so a bot wins a handle collision, self omitted, and members
without a display name skipped (their only handle would be a raw
userId, which isn't useful and wouldn't classify as a user mention).
- Popover rows colour the handle by kind; placeholder updated to
"mention a bot or member".
- send() still parses only bot mentions for invocation — mentioning a
member highlights but doesn't invoke anything.
Verified: unit test over candidate building + the match filter
(bots/users, self-exclusion, collision dedup, handle/label prefix);
Playwright regression that the popover still suggests and Enter inserts
"@claude " highlighted blue.
The per-open self-heal only fixes the project being opened. This boot-time
sweep heals the local user's own display name in EVERY project they belong
to, so peers see the corrected name without the user visiting each one.
How: read each project's locally-persisted (unencrypted) Automerge doc —
no key needed — to find ones where our profile is still the auto-seed
userId.slice(0,8). For only those, briefly open a real ProjectStore (which
persists + broadcasts the heal) on a DEDICATED OutboxClient, never the
shared app outbox, so the sweep can't collide with or tear down the
subscription of the project the user is actively viewing.
Scope/limits: only the local user's own name can be healed (a peer's real
name isn't available here); no-ops with no email or nothing stuck;
idempotent (a healed profile no longer matches the seed). Runs in the
existing boot harvest effect. Selection logic unit-tested.
When the local user had no email available (e.g. a fresh browser before
login persisted it), display-name seeding fell back to userId.slice(0,8)
and wrote THAT into the CRDT profile. Since ensureOwnDisplayName never
overwrote an existing profile, the id-looking "name" (e.g. c866bc2a)
stuck forever and synced to peers — so members, including yourself,
rendered as ids.
- Caller now only seeds from a real email-derived name; with no email it
leaves the profile unset and lets displayNameFor fall back to the id at
render time (not persisted).
- ensureOwnDisplayName self-heals: if the stored name is exactly the old
auto-seeded userId.slice(0,8) and a real name is now available, it
replaces it. A genuine user-chosen name is never touched.
Self-heal is per-user and runs when that user next opens a project with
an email cached; it then syncs to peers. Users with no cached email can
still set their name via the existing edit affordance. Verified with a
unit test over fresh/no-email/stuck-id/real-name cases.
ProjectBody returned early on waitingForFirstSync, but four hooks
(useRef + two useEffect + useState for drag/drop) ran *after* that
return. When a project was opened before its first peer snapshot
(isFresh) and the snapshot then arrived, waitingForFirstSync flipped to
false, the extra hooks ran, and React threw error #310 ("rendered more
hooks than during the previous render") — the intermittent "Something
broke" on opening a project, which a reload then masked because the doc
was synced by then.
Move the conditional return below every hook so the hook count is stable
across the waiting->synced transition. No other behavior change; the
handlers between are plain functions, and there are no hooks after the
relocated return.
Manual renames now lock the title: renameConversation({ manual: true })
sets a titleLocked flag on the conversation, and auto-naming refuses to
touch a locked title. Closes the race where a user renamed a still-
"Untitled" conversation while the title model was mid-generation — the
guard was only checked before the multi-second generateText await, so a
late result could clobber the fresh manual title. Auto-naming now also
re-reads the conversation after the await and discards its result if the
title was locked or changed during generation.
titleLocked is an optional CRDT field (absent on older conversations =
unlocked), so it merges and back-compats cleanly. Verified with a mock
model: unlocked/Untitled gets a title; locked and already-titled
conversations are untouched; and a manual rename (or any title change)
during generation wins over the model.
Auto-naming replayed the conversation to the model as user/assistant
turns, so the prompt ended on the bot's assistant reply. Models that
don't support assistant-message prefill reject that ("conversation must
end with a user message"), and the error was swallowed — the title just
never changed. Bot replies were unaffected because streamText always
ends the prompt on a user turn.
Fold the exchange into a single user message holding the transcript so
the prompt ends on a user turn for every provider. Verified with a mock
model that rejects assistant-ending prompts: old shape reproduces the
error, new shape returns the title. Diagnostics from the previous commit
kept (failure + no-usable-title + success), with the routine
already-titled / empty-history no-ops silenced to avoid console spam.
Auto-naming fires after a successful bot exchange but its result was
swallowed by a bare .catch(() => {}), so a failing or empty title
generation left no trace. Log every exit path (conv missing, title
already set, empty history, model returned no usable title) and the
thrown error at the call site, so a single repro with the console open
tells us which condition is breaking. No behavior change.
@mentions now get a persistent visual treatment everywhere, so selecting
a bot from the typeahead (or typing any mention) reads clearly:
- bots → blue chip
- human members → violet chip (distinct from bots)
- unknown @handles → muted, unchanged
Chat history already chip-rendered known bots; this extends it to also
classify human members (resolved from CRDT profiles + own email-local
part) with their own color. For the editable textareas (new-message
composer and the edit-a-turn view), adds a HighlightedTextarea: a
transparent textarea layered over a backdrop that paints mention swatches
behind the live text, so the caret/selection stay native and the
highlight tracks scrolling. Verified in a headless browser end to end
(composer swatches + history chips, both colors, unknown handle skipped).
New CSS vars: --mc-mention-{bot,user}-{fg,bg} (light + dark).
Owners can add people they already co-member projects with to a new
project without running the full invite-code exchange. Uses each
account's existing X25519 identity_pub: the owner seals the project key
to the collaborator's pubkey via an ephemeral-static ECDH sealed box, so
the add is fully offline — no fresh handshake, no second party online,
no schema change. The collaborator unwraps from their account master on
next login.
- crypto: peerWrap/peerUnwrapProjectKey + unwrapMyProjectKey (PRF
self-unwrap with peer-wrap fallback so all call sites handle both
formats)
- server: listKnownCollaborators / addMemberDirect / getIdentityPub;
GET /api/me/collaborators, POST /api/projects/:id/members
(owner-only, gated on actual shared membership)
- client: inline collaborator picker on owner project rows
- name cache: client-only IndexedDB store (nameCache, DB v6->7)
harvested from per-project CRDT profiles so the picker shows friendly
names instead of sliced user-ids; no server-side name store
The PRF master secret was stashed in sessionStorage, which iOS Safari
wipes on tab close. The server session cookie survived but the auth gate
needs both, so mobile users hit the re-auth screen on every tab reopen.
Move the stash to IndexedDB, encrypted under an AES-GCM key generated
with extractable:false. The key's raw bytes are never exposed to script
(exportKey throws), so an at-rest storage/profile dump yields only
ciphertext plus an unusable key handle -- stronger than plaintext in
localStorage, and the downstream key-derivation call sites are untouched
since we still recover the raw PRF in memory on restore.
- new src/lib/sessionstash.ts: stash/read/clear over a non-extractable
IDB key; clears the legacy sessionStorage key on sign-out
- idb.ts: add 'session' store, bump DB_VERSION 5 -> 6
- App.tsx: drop the inline sessionStorage stash; boot effect now restores
the PRF asynchronously (fetchMe + readStashedPrf in parallel)
Trust boundary: raises the at-rest bar; does not defend against live XSS
(script on the origin can still decrypt) -- same posture as the 1-year
session cookie. Existing users re-auth once, then the durable stash
takes over.
Root cause: the composer and other padded flex items stretched to the
pane width then added horizontal padding on top under the default
content-box, pushing the message entry box past the screen edge on
mobile.
- Universal box-sizing:border-box reset in index.html, killing the whole
class of padded-flex-item overflow at once; matches existing intent
since textarea/mainStyle/mobileShell already set border-box explicitly
- .hz-md table now display:block + overflow-x:auto and .hz-md img
max-width 100%, since wide GFM tables/images were the other offscreen
culprits
- conversation title-bar span min-width:0 + overflow-wrap:anywhere
- store.autoNameConversation(): one-shot title from opening exchange via
the invoked bot's model, guarded to only fire while title is 'Untitled'
- store.renameConversation(): merge-safe in-place rename
- Composer fires auto-name best-effort after bots complete
- ConversationPane gains a click-to-edit title bar (FilePane rename UX)
Bot-following — configure a bot once and it auto-joins every project
you open (current and future, including invited ones), regardless of
where you added it:
- New per-device recipe store (src/lib/userbots.ts) synced to the
server as a wrapped blob (wrapped_user_bots column, /api/me/bots),
mirroring the existing provider-key sync. Recipes carry no secrets;
the @pinger still spends their own provider key.
- Propagation on project open: seed each recipe into the project CRDT
with bot id = recipe id so it converges across devices and re-opens;
per-device seed tracking so a manual removal sticks.
- Startup harvest reads every project's locally-persisted (plaintext)
Automerge doc and pulls the user's own bots up into the library, so
the set is complete no matter which project is opened first. Sidebar
"Add bot" also registers the bot in the library.
- New IDB stores + DB v5; migration v8 adds the server column.
- "My bots" manager in Settings.
Navigation — fix the back button:
- Opening a project now pushState's a history entry (was replaceState,
so back had nothing to return to). In-app "back to Projects"
delegates to history.back(); popstate resolves #p=<id> via an
App-held project list so forward/back into a project works while the
list is unmounted. History normalized on mount so deep-link/reload
into a project still has the list beneath it.
- Sidebar: even spacing, left-aligned rows, lighter section labels,
bordered project header, sticky footer divider. Drops the double-padding
bug on the mobile drawer.
- Drops both passkey/passphrase nudge banners (legacy + active).
- ProjectList: trims the 48px top margin that swallowed the top of the
mobile viewport.
- index.html: lifts type + spacing scale into CSS vars so components stop
hardcoding pixel sizes.
- server/auth.ts: non-prod escape hatch so dev@hezo.local can self-register
without an invite (one-click dev sign-in).
- vite.config.ts: skipWaiting + clientsClaim so a deploy takes over on the
first reload instead of waiting for every tab to close.
Replace the brief "passkey or passphrase" section with one that
matches the current reality: both methods are enrolled at signup,
sit on the same master, and the wrap pattern lets multiple
passkeys coexist. Mention profiles in the CRDT contents and the
new server files (paths.ts, secret.ts, schemas/) in the layout.
Fixes "← Projects" doing nothing. initialProjectIdRef captured the
URL hash at App mount and was never cleared, so every remount of
ProjectList (which happens on every back navigation) consumed it
and immediately re-opened the same project. The user saw the back
click and bounced straight back to the project view.
One useEffect that nulls the ref once activeProject is set
removes the loop without disturbing the "reload restores your
last project" behavior.
Prepend a "Current production deployment" section so the next
session (or me, three weeks from now) doesn't have to rediscover
that the repo is already linked, the service is named `centrosome`
(v1 codename, not `hezo`), and shipping is just `railway up`.
The HMAC key is set on Railway and should not be regenerated.
The previous commit stored the master-wrap at users.wrapped_master_passkey,
which couldn't support multiple passkeys: each passkey has its own
PRF output, so each needs its own wrap. A legacy passkey-first user
adding a second passkey would silently break their first device's
login.
Moves the column down to webauthn_credentials so each credential
carries its own wrap. Null = legacy cred (login falls back to
master = PRF directly). Non-null = unwrap with deriveKey(PRF).
Both paths coexist per credential, so legacy creds keep working
while new enrollments use the wrap.
- migration v7: ALTER TABLE webauthn_credentials ADD COLUMN
wrapped_master_passkey BLOB; copy v6 user-level blobs down
for users with exactly one credential; ALTER TABLE users
DROP COLUMN wrapped_master_passkey
- server/auth.ts: finishPasskeyEnroll writes the wrap to the
new credential row in one INSERT (replaces the prior
transaction over INSERT + UPDATE users); finishAuthentication
SELECTs the wrap from the specific credential the user just
presented
- App.tsx: SettingsModal "Enroll passkey" now visible
unconditionally (it's per-device safe to add another); copy
swaps between "Passkey" and "Add another passkey" based on
identity.hasPasskey
Closes the asymmetry from the previous commit. New endpoint
/api/auth/passkey/enroll/{begin,finish} lets an already-authenticated
user attach a passkey to their account. The new passkey's PRF
output derives a wrap key (domain-separated via
INFO_PASSKEY_MASTER_WRAP) that wraps the in-memory master; the
ciphertext lands in users.wrapped_master_passkey. Future passkey
logins unwrap with that key to recover the same master that a
passphrase login would yield.
- migration v6: ALTER TABLE users ADD COLUMN
wrapped_master_passkey BLOB (nullable)
- server/auth.ts: makePasskeyEnrollOptions +
finishPasskeyEnroll; finishAuthentication now returns
wrappedMasterPasskey (null for legacy passkey-first signups
whose master IS the PRF result)
- server/index.ts: enroll routes + login finish ships the
wrap blob
- Identity gains hasPasskey, computed everywhere alongside
hasPassphrase
- crypto.ts: derivePasskeyMasterWrapKey (HKDF info
"hezo.passkey-master-wrap.v1") — distinct from the project
wrap key so neither leaks the other on compromise
- src/lib/auth.ts: loginPasskey unwraps when the server
returns a blob, else falls back to master = PRF;
enrollPasskey(email, master) runs the ceremony, wraps, and
posts
- App.tsx: SettingsModal grows a parallel "Enroll passkey on
this device" section (visible only when hasPasskey is
false); PasskeyNudge banner mirrors PassphraseNudge; on
successful enrollment, the in-memory identity is
optimistically updated so the banner + section disappear
without a server round-trip
Every new account now has both auth methods from day one — the
passkey signup form gains a passphrase field that wraps the same
master (= PRF result) and lands in the user row in the same
INSERT as the credential.
Existing passkey-only accounts get a top-of-app banner nudging
them into Settings → "enroll passphrase," which is the path that
already existed.
- src/lib/auth.ts registerPasskey: requires a passphrase, runs
Argon2id locally, ships wrappedMaster + salt + verifier +
argon2Params alongside the WebAuthn finish payload
- server/auth.ts RegisterFinishInput + finishRegistration:
requires + persists those fields in a single transaction
with the user/credential insert
- server/index.ts /api/auth/register/finish: validates the new
body shape
- Identity.hasPassphrase (server-computed via
passphrase_verifier IS NOT NULL) threaded through every
identity-returning path
- App.tsx AuthForm: shows the passphrase field in register +
passkey mode with a one-liner explaining why
- App.tsx PassphraseNudge banner: visible when
identity.hasPassphrase === false, opens the existing
Settings modal which already has the enrollment UI
The server now keeps `email_hash` (HMAC-SHA256 of the lowercased
typed email under a deployment-wide secret) and never persists
plaintext email. Lookup flow is unchanged for the user: type your
email at login, server HMACs it in-memory and queries by hash.
- new server/secret.ts: deployment HMAC key from
HEZO_EMAIL_HMAC_KEY env var, else generated and persisted to
`<HEZO_DATA>/email_hmac.key` on first boot
- new server/paths.ts: extracts DATA_DIR so secret.ts and
schemas/index.ts can both reach it without cycling through
db.ts
- migration framework: optional `apply(db)` JS step on a
Migration, plus a `useTransaction: false` opt-out for
migrations that need to toggle PRAGMA foreign_keys
- migration v5: rebuild users + webauthn_challenges via the
standard CREATE…/INSERT…/DROP…/RENAME dance (SQLite can't
DROP a UNIQUE column directly); existing emails get HMAC'd
in place; runs foreign_key_check at the end
- server/auth.ts: every email-shaped lookup, insert, and
challenge row uses email_hash; Identity returned to the
client carries only userId (no email)
- server/projects.ts: members API drops email too; clients
don't need it now that display names live in the CRDT
- shared schema: Identity.email becomes optional (client-only,
filled from localStorage); Member loses email entirely
- src/lib/auth.ts: after passkey/passphrase login the typed
email is stashed in localStorage and merged back onto the
Identity client-side; logout clears
- App.tsx: displayNameFor + ensureOwnDisplayName tolerate a
missing identity.email (fall back to short userId)
- README + DEPLOY: document the HMAC key and the "back this
file up or orphan every account" caveat
Display name is now per-project user data, encrypted client-side
and synced via the outbox — the server never sees it.
- migration v4: drop users.display_name
- server/auth.ts + projects.ts: strip display_name from all
queries and API responses; WebAuthn userDisplayName is now
computed inline from email at call-time (presentational only,
never stored)
- shared Identity loses displayName; Member keeps only
{ userId, email, role }
- new ProjectDoc.profiles: Record<userId, Profile> with a
displayName field; backfilled in loadDoc for older docs
- new ProjectStore.{setDisplayName, ensureOwnDisplayName}
- App: render member list + turn authors via displayNameFor()
helper (profile → email local-part → short userId fallback);
sidebar gains "appearing as <name> · edit" affordance;
seeds default on first project open per device
Lifts drag-and-drop from the FileSection sidebar up to the whole
project view via window-level listeners, with a full-viewport
"Drop files to upload" overlay while a drag is in progress. Drops
on a file row replace that file's contents in place (preserves id,
name, createdBy, createdAt); a Replace button in the FilePane does
the same via the file picker. Row drop calls
stopImmediatePropagation so the window handler doesn't also upload
the same file as a new entry.
New store method: replaceFileContent(id, { mime?, content?,
contentBytes? }).
FileSection grows an "Upload" button and accepts files dropped on
its sidebar region. Text files (md/txt/code/json/etc.) decode UTF-8
into the existing `content` field. PDFs land in two new optional
ProjectFile fields — `contentBytes: Uint8Array` and `mime` — and
render in the preview pane via an iframe over a blob URL.
Per-file cap is 1.5 MB so a whole-doc snapshot stays under the
outbox's 4 MB ciphertext limit. Edit tab is hidden for binary files;
they get a Download link instead.
A new joiner whose ProjectStore opened with no local doc would
createDoc() with its own root actor. If they made any local change
before adopting a peer snapshot, applyLocal would commit + broadcast
that empty/sparse doc to the project. On the recipient side, merging
that into a populated doc hit the same root-map LWW conflict that the
isFresh→adopt path was already there to prevent — only now the
populated peer was the loser, and their data was clobbered.
Two guards:
- applyLocal early-returns when isFresh; no mutation can land.
- broadcastSnapshot early-returns when isFresh; safety belt.
Project creators now seed local persistence with an empty doc right
after the server-side create, so ProjectStore.open() sees existing
state and clears isFresh — the creator IS the seeder and must be
able to write. Joiners (no local seed) stay isFresh until they adopt
a peer snapshot; UI shows a "Waiting for first sync" state.
Each device now persists a per-project watermark (highest createdAt
seen) and passes it on subscribe; server returns rows past that and
no longer deletes on ack. Outbox rows TTL out at 30d as before.
Fixes the multi-device race where two devices for the same user
shared a queue: whichever acked first deleted the row from under
the other. iOS in particular kept getting an empty project after
desktop's snapshot had already been ack-consumed by desktop itself.
Reset-local now also wipes the cursor so the next subscribe starts
from zero.
iOS browsers don't expose a per-origin "Clear data" — only a
nuke-everything path that wipes login state, IndexedDB, the whole
PWA cache. Adding an in-app reset just for the local CRDT copy
of one project so users can recover a corrupted-merge state
without losing keys / passkey credentials.
Reset:
- persistence.deleteDoc(projectId) wipes the IndexedDB entry
- Next open finds nothing, store goes through the isFresh path,
next peer snapshot is adopted wholesale.
Project itself isn't deleted server-side; other devices /
members are unaffected.
Bug: a device joining a project for the first time was creating a
local Automerge doc via Automerge.from(INITIAL) — fresh actor with
its own root operations (create the meta/files/conversations/turns/
bots maps). Merging that empty starter into a peer's populated doc
gave Automerge two concurrent assigns to each root field, and LWW
could pick the empty side, dropping the peer's data on the floor.
Worse, the merged-but-empty state could then be persisted and
broadcast back, propagating the loss.
Fix:
- crdt.ts: new adoptDoc(bytes) — Automerge.load + clone, so the
device shares the peer's root history but writes new changes
under its own actor.
- store.ts: ProjectStore tracks isFresh (true when there was no
IndexedDB entry at open). The first incoming snapshot on a
fresh store is adopted wholesale via adoptDoc rather than
merged. After adoption isFresh flips false and subsequent
snapshots merge normally (which is safe because the histories
now share a common root).
Existing populated-then-corrupted local docs aren't auto-healed
by this — they have to be wiped + re-seeded from a known-good
source (e.g. the push-project script).
`getRecipients` was reading membersRef.current at broadcast time. The
ProjectStore.open's auto-push fires right after subscribe, before the
async `/api/projects/:id/members` GET has resolved, so membersRef was
still []. The empty recipient list caused outbox.broadcast to no-op,
and the user's other devices never got the snapshot.
Now `getRecipients()` always includes the local user-id as a floor,
then adds any members that have loaded so far. The snapshot push
goes to (at least) the user's own user-id; server fanout reaches
every connected device of theirs.
The "container expanding past screen" + "page scrolls even with no
content below" symptoms both point to body itself growing past the
visible viewport on iOS. CSS-only constraints (max-width: 100vw,
overflow-x: hidden) weren't enough — iOS treated the body as
scrollable and rubber-banded.
- index.html: html / body get `overflow: hidden`. #root is
`position: fixed; inset: 0; width: 100dvw; height: 100dvh` —
literally bounded to the visible viewport. dvw/dvh respond to
the URL bar without growing the document.
- Add `input, textarea, select, button { min-width: 0 }` so iOS
form intrinsic widths can't push their flex/grid parents wider.
- Bump default input font-size to 16px so iOS Safari doesn't
auto-zoom on focus.
- Wrap AuthForm and ProjectList in a pageScrollerStyle div
(height/width 100%, overflowY auto) so their cards still scroll
on a short viewport even though the body itself can't.
This matches how the ConversationPane already scrolled — outer
shell is fixed, inner panes own their overflow.
Accounts predating the sync feature have keys only in IndexedDB
on the device where they were entered, with nothing on the
server. Without intervention, the other device's loadKeysSynced
sees null and shows an empty Settings panel.
loadKeysSynced now: when the server returns null AND the local
cache has at least one key, push the local set up. Next device
load gets the populated state.
Fire-and-forget (.catch -> warn); no blocking on network.
Sends issued before the WebSocket finished its handshake (or
during a reconnect) were silently dropped. That's why the
auto-snapshot-on-open could vanish when ProjectStore.open
called broadcastSnapshot while the socket was still negotiating
— iOS would then see nothing because no entry ever made it to
the server outbox.
Now: send() pushes to a `pending` queue if the socket isn't OPEN,
capped at 200 to avoid unbounded growth on a permanent outage.
The 'open' handler re-sends subscribes (existing behavior) and
then flushes the pending queue.
- SettingsModal: card is now max-height 90dvh with overflowY: auto,
so on a small screen the contents scroll inside the modal instead
of the top getting clipped while the page behind scrolls. Backdrop
gains overscroll-behavior: contain to keep iOS rubber-band from
passing through.
- Inputs: min-width: 0 so iOS Safari's intrinsic input min-width
doesn't push the auth/projects cards past the viewport.
- Root: html / body / #root pinned to width: 100% / max-width: 100vw /
overflow-x: hidden. Long URLs / tokens get overflow-wrap: anywhere
so an unbroken string in a <p> can't widen the card.
Two bugs reported on iOS:
1. Inside a project, the top bar / hamburger / drawer didn't show.
mainStyle used `height: 100%`, which is correct inside the
desktop Panel but pushed `<main>` to a full-viewport height in
the mobile flex column (next to the header), shoving everything
else off-screen. Introduce a separate mobileMainStyle with
`flex: 1` + `min-height: 0`. Mobile shell switches to `100dvh`
so the URL bar's shift doesn't lop content off.
2. Login + projects list pages widened past the device width.
cardStyle and the projects list outer div had no `width: 100%`
+ `box-sizing: border-box`, so their `maxWidth` could be
exceeded by inner content's natural width on narrow viewports.
Belt-and-braces on html/body with `width: 100%` +
`overflow-x: hidden`.
`@media (max-width: 767px)` switches the project view from the
desktop three-pane PanelGroup to a mobile shell:
- Top bar with hamburger, project name, settings (⚙)
- Main: just the conversation pane (full width)
- Tap hamburger → drawer slides over from the left with the full
sidebar (members, bots, files, conversations). Tapping any item
closes the drawer.
- Tapping a file opens FilePane as a full-screen overlay (z-index
60). × in the file header closes back to the conversation.
Extracted the sidebar inner content into <SidebarContent> so it
renders in either the desktop <aside> or the mobile drawer without
duplication. Pick-and-close handlers route through ProjectBody so
the same React state drives both layouts.
useIsMobile hook listens to a `(max-width: 767px)` matchMedia query
and triggers re-render on resize / orientation change.
Walking back the "per-device only" design for both provider keys
and project content. Each device still has a local IndexedDB cache;
server now also carries an encrypted blob (or fans out via outbox)
so opening on a second device gets you the same view.
Provider keys (Anthropic, OpenAI, etc.):
- schemas v3 migration: ALTER TABLE users ADD wrapped_provider_keys.
- server/auth.ts: getWrappedProviderKeys / setWrappedProviderKeys.
- server/index.ts: GET/PUT /api/me/keys (auth required, opaque blob).
- lib/crypto.ts: deriveProviderKeysWrapKey (domain-separated HKDF from
the master secret), plus reusable aesGcmEncrypt/Decrypt helpers.
- lib/keystore.ts: loadKeysSynced + saveKeysSynced. Local IDB stays
as the offline cache; server is authoritative when reachable.
- App.tsx: useEffect pulls from server on auth, push on every save.
Project state across user's own devices:
- ProjectStore.getRecipients now includes self. Outbox fans out to
every connection of the user's id, so the other devices subscribed
to the project receive the snapshot. Sender's own connection
receives back; merge is a no-op since heads match.
- ProjectStore.open: if local doc has any heads, push a fresh
snapshot through the outbox right after subscribe. That way a
brand-new device that comes online later picks up the snapshot
from the outbox queue without needing the source device to mutate
again.
Two bugs:
1. mainStyle used `flex: 1` from when `<main>` lived inside a flex
row in ProjectBody. Phase 5 wrapped main in a `<Panel>`, where
`flex: 1` no longer gives it a height — so the inner scroller
couldn't compute a positive scroll viewport and was stuck. Switch
to `height: 100%` to fill the Panel.
2. Snap-to-latest didn't always fire on conversation open because
the existing useEffect's deps (lastTurn id/length, turns.length)
don't change if you reopen the same conversation. Add a conv-id
effect that forces stick + scrolls to bottom, with a 50ms retry
to catch reflows from markdown rendering.
Used `require('node:crypto')` inline for timingSafeEqual + createHash,
but the server is ESM (package.json type=module) — `require` isn't
defined in that scope so the finish call threw "require is not defined"
and the browser surfaced it as the login error. Hoist the imports to
the top of the file.
Adds an Argon2id-derived passphrase as a second login method that
produces the same account key as the user's passkey. Designed for
clients/authenticators that don't expose WebAuthn PRF — most notably
iOS via Proton Pass / Bitwarden / etc., where PRF isn't yet plumbed
through the iOS Credential Provider.
Architecture:
- One "master secret" per account: for passkey users this is the PRF
output (unchanged); for passphrase-only signups it's random 32 bytes.
- Argon2id(passphrase, salt, params) → 64 bytes split into a 32-byte
AES-GCM wrap key (in-browser only) and a 32-byte verifier (sent
server-side for login comparison via timingSafeEqual).
- Server stores wrapped_master + salt + verifier + argon2 params on
the user row. Server never sees the wrap key or the master.
Server:
- schemas/index.ts v2 migration: ALTER TABLE users to add the four
new columns (nullable; existing passkey-only rows leave them blank).
- auth.ts: passphraseLoginBegin / passphraseLoginFinish /
passphraseRegister / passphraseSetup. Login uses a dummy salt for
unknown emails so timing doesn't trivially leak account existence.
- index.ts: POST /api/auth/passphrase/{login/begin,login/finish,
register,setup}.
Client:
- crypto.ts: deriveFromPassphrase via @noble/hashes argon2id. Helpers
for random salt + master secret.
- lib/auth.ts: registerPassphrase / loginPassphrase / setupPassphrase.
Both passkey and passphrase paths return the same AuthResult shape
(identity + 32-byte secret), so downstream code (project key wrap,
identity_pub derivation) is unchanged.
- App.tsx AuthForm: method toggle (passkey ↔ passphrase). Below the
submit button, "Passkey doesn't work? Use passphrase instead" /
"← Back to passkey".
- App.tsx SettingsModal: "Passphrase backup" section. Sign in via
passkey on desktop (PRF works), enroll a passphrase from Settings,
then iOS can sign in via email + passphrase.
Defaults: argon2id m=32MiB, t=2, p=1 in browser. ~0.5–1s per derive
on a modern device.
The earlier message blamed only the browser, but PRF support is
joint: both the browser/UA AND the passkey provider need to
support it. iCloud Keychain, 1Password, Bitwarden support PRF on
iOS. Some third-party iOS Credential Providers (Proton Pass etc.)
don't yet expose PRF through iOS — same passkey works on their
desktop extension but fails on the iOS app. Mention this in the
iOS path so users know to try iCloud Keychain.
On iOS, only Safari 18+ exposes the WebAuthn PRF extension; Firefox
iOS / Orion / Chrome iOS all use WKWebView, which doesn't expose
PRF to JS. The error now detects iOS and points the user to Safari
specifically; non-iOS callers get a list of supported desktop
browser versions. (Real fix is a passphrase-based fallback wrap
key for browsers without PRF — out of scope for now.)
- TurnRow body now renders via ReactMarkdown (remark-gfm), so
headings, code blocks, lists, tables etc. show up like the file
preview already does. Mention chips still work — a small
processMentions helper walks ReactMarkdown's children and runs
the existing `renderWithMentions` over any string children at
paragraph or list-item scope.
- App: stash the PRF secret in sessionStorage on auth, restore on
boot. A page reload no longer kicks the user back to the
passkey re-auth prompt; closing the tab still clears it. Same
trust boundary as the rest of v2's prototype shortcuts —
anything with XSS on hezo.app could read it, but so could the
React heap.
- Layout: wrap sidebar / main / file pane in react-resizable-panels
with PanelResizeHandle bars between them. autoSaveId persists the
user's chosen widths in localStorage. Sidebar/file-pane styles
drop fixed widths so the Panel owns sizing.
- Markdown: file preview was rendering as a `<pre>` of raw markdown.
Swap in react-markdown + remark-gfm, scoped to a `.hz-md` class.
- index.html: add `.hz-md` typography (headings, code, blockquotes,
tables) tuned to the existing theme tokens, and a `.hz-resize`
class for the panel handles.
- Files sidebar: `×` delete button per row (matches the
Conversations row), confirmation prompt. Closes the file pane if
the currently-open file is the one deleted.
Phase 4 dropped the file pane when ProjectView was rewritten into
sidebar+main; the CRDT still carries files (the Greece migration
imported main.md + summary.md) but they were invisible. Bringing
back a third pane.
- src/lib/crdt.ts: add ProjectFile type + files field to ProjectDoc.
Backfill files: {} in loadDoc for older docs. Add fileList selector.
- src/lib/store.ts: createFile / updateFileContent / renameFile /
deleteFile mutators (regular applyLocal flow, broadcasts a CRDT
snapshot to peers).
- src/App.tsx: FileSection in sidebar (above Conversations). FilePane
on the right when a file is selected, with preview / edit toggle
+ rename-on-click on the title.
The v1 push-project pointed at a server endpoint that no longer
exists. The v2 path can't send content to the server, but it can
ship content to a logged-in browser via a URL fragment, same trust
model as invite links: K_proj plaintext + Automerge doc bytes ride
in `#import-project=`. The fragment never leaves the local
browser, gets scrubbed on load, gets wrapped and POST'd to
/api/projects after auth.
- scripts/push-project.mjs: rewritten to build an Automerge doc
from a local v1 SQLite (conversations + turns + text files;
binary files skipped — v2 hasn't wired blob transport yet) and
open the import URL.
- src/main.tsx: handle `#import-project=` by stashing the raw
payload in sessionStorage and scrubbing the hash, then mount.
- src/App.tsx: after auth, consume the stash. Derive wrap key from
PRF, wrap K_proj, POST /api/projects to mint the row, persist
the Automerge doc bytes under the server-assigned project id.
Cleared on success or alert-and-clear on failure.
Two safeguards on bot file writes:
1. edit_file (surgical): replace an exact old_string with new_string,
matched against current content. A non-matching or ambiguous match is
rejected without changing the file, and untouched regions — including a
human's concurrent edit elsewhere — are preserved. Preferred over
update_file for changes.
2. update_file (whole-file rewrite) now uses optimistic concurrency: the
bot must read_file this turn AND the content must be unchanged since, or
the write is rejected and the bot is told to re-read — so a concurrent
human edit can't be silently clobbered. A per-invocation read-snapshot
map (seeded by read_file/create_file and refreshed after each write)
backs the check.
Refactor: the tool set moved to a standalone buildFileTools(deps) so the
write-safety logic is unit-testable against a fake store; ProjectStore
delegates to it. System preamble updated to steer bots to edit_file.
Verified the real buildFileTools end to end (12 cases): surgical
replace/non-match/ambiguous/replace_all, must-read-first, stale-rejection,
applies-when-fresh, and surgical-edit-preserves-concurrent-human-change.
Bots were chat-only — the model got just the conversation turns and no
tools. Now invokeBot hands every bot a file tool set (AI SDK tool-calling)
and a system preamble listing the project's files:
- list_files — names + text/binary
- read_file — full text contents by name
- create_file — new text file (rejects an existing name)
- update_file — replace an existing file's text
Each tool maps to an existing store mutation, so a bot's writes persist
and broadcast to peers exactly like a human's; mutations are attributed to
the user running the bot. stopWhen: stepCountIs(8) allows read→edit→reply
loops (without it the model would call a tool and never reply). Tools
return human-readable strings, including soft errors, so the model can
recover rather than the run throwing. Binary (PDF) files are listed but
not text-readable/editable.
Verified the tool-loop wiring with a mock model (create → update →
closing text across steps; executes mutate state; stopWhen continues).
Live tool-calling depends on a real provider key + the model choosing to
call tools.
A "+" button on the project sidebar's Members header (owner-only) opens a
picker of people you've collaborated with before who aren't already in
this project. Selecting one adds them directly — unwrap our project key,
re-seal it to their identity_pub, POST the member row — the same offline
direct-add path as the project-list picker, no invite link or message
mention needed. The member list refreshes on success.
Wiring: ProjectView loads the known-collaborators list + warms the name
cache and owns addMember (it has prf + the wrapped key); SidebarContent
renders the +/popup and filters out current members. SidebarHeader gained
an optional right-side action slot.
Note: the in-editor @mention list already sources only from project
members (since the earlier change), so there's no accidental-add path from
mentioning — adding a member is now an explicit, separate action.
Verified in a headless browser (mocked collaborator): the + shows on
Members, the popup lists the collaborator, and selecting fires the
add-member POST with a peer-sealed key.
Multiparty conversations: a "↩ reply" button under every message from
someone else (bots and other members) prefills the composer with that
author's @mention, so you can follow up without remembering to type it.
Shift+R does the same for the most recent message from someone else when
focus isn't in a text field — and that message's reply link advertises
the shortcut as "↩ reply (⇧R)" so it's discoverable.
Implementation: Composer exposes an imperative prefillReply(mention) (via
forwardRef) that prepends the mention and focuses — keeping the composer's
text state local so per-keystroke typing doesn't re-render the turn list.
ConversationPane owns the reply target + the window keydown handler; reply
buttons skip your own messages (no self-mention). Bots resolve to their
name, people to their display-name handle.
Verified in a headless browser: reply button appears on a bot turn with
the ⇧R hint, and both clicking it and pressing Shift+R prefill "@claude ".
You can now @-mention from the edit view, not just the composer — handy
when you forgot to address the bot. On save, any bot mentioned now but
not before the edit is invoked (only the newly-added ones, so a bot that
already replied isn't pinged again). Missing-key / invoke errors surface
inline under the message; a "Sending to bot…" note shows while it runs.
Refactor: the typeahead (popover + mention state + insert) is extracted
into a reusable MentionInput used by both the composer and the edit
textarea. onSubmit fires on ⌘/Ctrl+Enter, onCancel on Escape (when the
popover is closed). No behavior change to the composer.
Verified in a headless browser: edit view shows the typeahead, accepting
inserts the handle, the edited message renders the highlighted mention,
and saving with a newly-added @bot fires the invoke.
Mention candidates were built from doc.profiles (users who'd set a name)
minus self — so a collaborator who hadn't named themselves never appeared,
and a solo/own-name project showed no users at all (only bots).
Source candidates from the authoritative member list instead, resolving
each name via displayNameFor (falling back to the short id), and include
self (labelled "you"). buildMentionSets now also classifies every member's
resolved handle as a user mention, so a typed @id renders violet and
upgrades automatically once that member picks a real name. Bots still win
a handle collision.
Verified in-app: typing @ lists the bot (blue) and the member (violet,
"you"); covered the candidate/classify logic with the earlier unit test.
The composer's mention autocomplete now offers human members alongside
bots. Selecting a member inserts their handle (normalized from their
display name, the same form the renderer classifies), so it lights up
violet; bots stay blue. Matching is on either the handle or the visible
label, so "@di" finds a member named "Dietrich".
- buildMentionCandidates: bots + named members from the CRDT profiles,
deduped so a bot wins a handle collision, self omitted, and members
without a display name skipped (their only handle would be a raw
userId, which isn't useful and wouldn't classify as a user mention).
- Popover rows colour the handle by kind; placeholder updated to
"mention a bot or member".
- send() still parses only bot mentions for invocation — mentioning a
member highlights but doesn't invoke anything.
Verified: unit test over candidate building + the match filter
(bots/users, self-exclusion, collision dedup, handle/label prefix);
Playwright regression that the popover still suggests and Enter inserts
"@claude " highlighted blue.
The per-open self-heal only fixes the project being opened. This boot-time
sweep heals the local user's own display name in EVERY project they belong
to, so peers see the corrected name without the user visiting each one.
How: read each project's locally-persisted (unencrypted) Automerge doc —
no key needed — to find ones where our profile is still the auto-seed
userId.slice(0,8). For only those, briefly open a real ProjectStore (which
persists + broadcasts the heal) on a DEDICATED OutboxClient, never the
shared app outbox, so the sweep can't collide with or tear down the
subscription of the project the user is actively viewing.
Scope/limits: only the local user's own name can be healed (a peer's real
name isn't available here); no-ops with no email or nothing stuck;
idempotent (a healed profile no longer matches the seed). Runs in the
existing boot harvest effect. Selection logic unit-tested.
When the local user had no email available (e.g. a fresh browser before
login persisted it), display-name seeding fell back to userId.slice(0,8)
and wrote THAT into the CRDT profile. Since ensureOwnDisplayName never
overwrote an existing profile, the id-looking "name" (e.g. c866bc2a)
stuck forever and synced to peers — so members, including yourself,
rendered as ids.
- Caller now only seeds from a real email-derived name; with no email it
leaves the profile unset and lets displayNameFor fall back to the id at
render time (not persisted).
- ensureOwnDisplayName self-heals: if the stored name is exactly the old
auto-seeded userId.slice(0,8) and a real name is now available, it
replaces it. A genuine user-chosen name is never touched.
Self-heal is per-user and runs when that user next opens a project with
an email cached; it then syncs to peers. Users with no cached email can
still set their name via the existing edit affordance. Verified with a
unit test over fresh/no-email/stuck-id/real-name cases.
ProjectBody returned early on waitingForFirstSync, but four hooks
(useRef + two useEffect + useState for drag/drop) ran *after* that
return. When a project was opened before its first peer snapshot
(isFresh) and the snapshot then arrived, waitingForFirstSync flipped to
false, the extra hooks ran, and React threw error #310 ("rendered more
hooks than during the previous render") — the intermittent "Something
broke" on opening a project, which a reload then masked because the doc
was synced by then.
Move the conditional return below every hook so the hook count is stable
across the waiting->synced transition. No other behavior change; the
handlers between are plain functions, and there are no hooks after the
relocated return.
Manual renames now lock the title: renameConversation({ manual: true })
sets a titleLocked flag on the conversation, and auto-naming refuses to
touch a locked title. Closes the race where a user renamed a still-
"Untitled" conversation while the title model was mid-generation — the
guard was only checked before the multi-second generateText await, so a
late result could clobber the fresh manual title. Auto-naming now also
re-reads the conversation after the await and discards its result if the
title was locked or changed during generation.
titleLocked is an optional CRDT field (absent on older conversations =
unlocked), so it merges and back-compats cleanly. Verified with a mock
model: unlocked/Untitled gets a title; locked and already-titled
conversations are untouched; and a manual rename (or any title change)
during generation wins over the model.
Auto-naming replayed the conversation to the model as user/assistant
turns, so the prompt ended on the bot's assistant reply. Models that
don't support assistant-message prefill reject that ("conversation must
end with a user message"), and the error was swallowed — the title just
never changed. Bot replies were unaffected because streamText always
ends the prompt on a user turn.
Fold the exchange into a single user message holding the transcript so
the prompt ends on a user turn for every provider. Verified with a mock
model that rejects assistant-ending prompts: old shape reproduces the
error, new shape returns the title. Diagnostics from the previous commit
kept (failure + no-usable-title + success), with the routine
already-titled / empty-history no-ops silenced to avoid console spam.
Auto-naming fires after a successful bot exchange but its result was
swallowed by a bare .catch(() => {}), so a failing or empty title
generation left no trace. Log every exit path (conv missing, title
already set, empty history, model returned no usable title) and the
thrown error at the call site, so a single repro with the console open
tells us which condition is breaking. No behavior change.
@mentions now get a persistent visual treatment everywhere, so selecting
a bot from the typeahead (or typing any mention) reads clearly:
- bots → blue chip
- human members → violet chip (distinct from bots)
- unknown @handles → muted, unchanged
Chat history already chip-rendered known bots; this extends it to also
classify human members (resolved from CRDT profiles + own email-local
part) with their own color. For the editable textareas (new-message
composer and the edit-a-turn view), adds a HighlightedTextarea: a
transparent textarea layered over a backdrop that paints mention swatches
behind the live text, so the caret/selection stay native and the
highlight tracks scrolling. Verified in a headless browser end to end
(composer swatches + history chips, both colors, unknown handle skipped).
New CSS vars: --mc-mention-{bot,user}-{fg,bg} (light + dark).
Owners can add people they already co-member projects with to a new
project without running the full invite-code exchange. Uses each
account's existing X25519 identity_pub: the owner seals the project key
to the collaborator's pubkey via an ephemeral-static ECDH sealed box, so
the add is fully offline — no fresh handshake, no second party online,
no schema change. The collaborator unwraps from their account master on
next login.
- crypto: peerWrap/peerUnwrapProjectKey + unwrapMyProjectKey (PRF
self-unwrap with peer-wrap fallback so all call sites handle both
formats)
- server: listKnownCollaborators / addMemberDirect / getIdentityPub;
GET /api/me/collaborators, POST /api/projects/:id/members
(owner-only, gated on actual shared membership)
- client: inline collaborator picker on owner project rows
- name cache: client-only IndexedDB store (nameCache, DB v6->7)
harvested from per-project CRDT profiles so the picker shows friendly
names instead of sliced user-ids; no server-side name store
The PRF master secret was stashed in sessionStorage, which iOS Safari
wipes on tab close. The server session cookie survived but the auth gate
needs both, so mobile users hit the re-auth screen on every tab reopen.
Move the stash to IndexedDB, encrypted under an AES-GCM key generated
with extractable:false. The key's raw bytes are never exposed to script
(exportKey throws), so an at-rest storage/profile dump yields only
ciphertext plus an unusable key handle -- stronger than plaintext in
localStorage, and the downstream key-derivation call sites are untouched
since we still recover the raw PRF in memory on restore.
- new src/lib/sessionstash.ts: stash/read/clear over a non-extractable
IDB key; clears the legacy sessionStorage key on sign-out
- idb.ts: add 'session' store, bump DB_VERSION 5 -> 6
- App.tsx: drop the inline sessionStorage stash; boot effect now restores
the PRF asynchronously (fetchMe + readStashedPrf in parallel)
Trust boundary: raises the at-rest bar; does not defend against live XSS
(script on the origin can still decrypt) -- same posture as the 1-year
session cookie. Existing users re-auth once, then the durable stash
takes over.
Root cause: the composer and other padded flex items stretched to the
pane width then added horizontal padding on top under the default
content-box, pushing the message entry box past the screen edge on
mobile.
- Universal box-sizing:border-box reset in index.html, killing the whole
class of padded-flex-item overflow at once; matches existing intent
since textarea/mainStyle/mobileShell already set border-box explicitly
- .hz-md table now display:block + overflow-x:auto and .hz-md img
max-width 100%, since wide GFM tables/images were the other offscreen
culprits
- conversation title-bar span min-width:0 + overflow-wrap:anywhere
- store.autoNameConversation(): one-shot title from opening exchange via
the invoked bot's model, guarded to only fire while title is 'Untitled'
- store.renameConversation(): merge-safe in-place rename
- Composer fires auto-name best-effort after bots complete
- ConversationPane gains a click-to-edit title bar (FilePane rename UX)
Bot-following — configure a bot once and it auto-joins every project
you open (current and future, including invited ones), regardless of
where you added it:
- New per-device recipe store (src/lib/userbots.ts) synced to the
server as a wrapped blob (wrapped_user_bots column, /api/me/bots),
mirroring the existing provider-key sync. Recipes carry no secrets;
the @pinger still spends their own provider key.
- Propagation on project open: seed each recipe into the project CRDT
with bot id = recipe id so it converges across devices and re-opens;
per-device seed tracking so a manual removal sticks.
- Startup harvest reads every project's locally-persisted (plaintext)
Automerge doc and pulls the user's own bots up into the library, so
the set is complete no matter which project is opened first. Sidebar
"Add bot" also registers the bot in the library.
- New IDB stores + DB v5; migration v8 adds the server column.
- "My bots" manager in Settings.
Navigation — fix the back button:
- Opening a project now pushState's a history entry (was replaceState,
so back had nothing to return to). In-app "back to Projects"
delegates to history.back(); popstate resolves #p=<id> via an
App-held project list so forward/back into a project works while the
list is unmounted. History normalized on mount so deep-link/reload
into a project still has the list beneath it.
- Sidebar: even spacing, left-aligned rows, lighter section labels,
bordered project header, sticky footer divider. Drops the double-padding
bug on the mobile drawer.
- Drops both passkey/passphrase nudge banners (legacy + active).
- ProjectList: trims the 48px top margin that swallowed the top of the
mobile viewport.
- index.html: lifts type + spacing scale into CSS vars so components stop
hardcoding pixel sizes.
- server/auth.ts: non-prod escape hatch so dev@hezo.local can self-register
without an invite (one-click dev sign-in).
- vite.config.ts: skipWaiting + clientsClaim so a deploy takes over on the
first reload instead of waiting for every tab to close.
Replace the brief "passkey or passphrase" section with one that
matches the current reality: both methods are enrolled at signup,
sit on the same master, and the wrap pattern lets multiple
passkeys coexist. Mention profiles in the CRDT contents and the
new server files (paths.ts, secret.ts, schemas/) in the layout.
Fixes "← Projects" doing nothing. initialProjectIdRef captured the
URL hash at App mount and was never cleared, so every remount of
ProjectList (which happens on every back navigation) consumed it
and immediately re-opened the same project. The user saw the back
click and bounced straight back to the project view.
One useEffect that nulls the ref once activeProject is set
removes the loop without disturbing the "reload restores your
last project" behavior.
Prepend a "Current production deployment" section so the next
session (or me, three weeks from now) doesn't have to rediscover
that the repo is already linked, the service is named `centrosome`
(v1 codename, not `hezo`), and shipping is just `railway up`.
The HMAC key is set on Railway and should not be regenerated.
The previous commit stored the master-wrap at users.wrapped_master_passkey,
which couldn't support multiple passkeys: each passkey has its own
PRF output, so each needs its own wrap. A legacy passkey-first user
adding a second passkey would silently break their first device's
login.
Moves the column down to webauthn_credentials so each credential
carries its own wrap. Null = legacy cred (login falls back to
master = PRF directly). Non-null = unwrap with deriveKey(PRF).
Both paths coexist per credential, so legacy creds keep working
while new enrollments use the wrap.
- migration v7: ALTER TABLE webauthn_credentials ADD COLUMN
wrapped_master_passkey BLOB; copy v6 user-level blobs down
for users with exactly one credential; ALTER TABLE users
DROP COLUMN wrapped_master_passkey
- server/auth.ts: finishPasskeyEnroll writes the wrap to the
new credential row in one INSERT (replaces the prior
transaction over INSERT + UPDATE users); finishAuthentication
SELECTs the wrap from the specific credential the user just
presented
- App.tsx: SettingsModal "Enroll passkey" now visible
unconditionally (it's per-device safe to add another); copy
swaps between "Passkey" and "Add another passkey" based on
identity.hasPasskey
Closes the asymmetry from the previous commit. New endpoint
/api/auth/passkey/enroll/{begin,finish} lets an already-authenticated
user attach a passkey to their account. The new passkey's PRF
output derives a wrap key (domain-separated via
INFO_PASSKEY_MASTER_WRAP) that wraps the in-memory master; the
ciphertext lands in users.wrapped_master_passkey. Future passkey
logins unwrap with that key to recover the same master that a
passphrase login would yield.
- migration v6: ALTER TABLE users ADD COLUMN
wrapped_master_passkey BLOB (nullable)
- server/auth.ts: makePasskeyEnrollOptions +
finishPasskeyEnroll; finishAuthentication now returns
wrappedMasterPasskey (null for legacy passkey-first signups
whose master IS the PRF result)
- server/index.ts: enroll routes + login finish ships the
wrap blob
- Identity gains hasPasskey, computed everywhere alongside
hasPassphrase
- crypto.ts: derivePasskeyMasterWrapKey (HKDF info
"hezo.passkey-master-wrap.v1") — distinct from the project
wrap key so neither leaks the other on compromise
- src/lib/auth.ts: loginPasskey unwraps when the server
returns a blob, else falls back to master = PRF;
enrollPasskey(email, master) runs the ceremony, wraps, and
posts
- App.tsx: SettingsModal grows a parallel "Enroll passkey on
this device" section (visible only when hasPasskey is
false); PasskeyNudge banner mirrors PassphraseNudge; on
successful enrollment, the in-memory identity is
optimistically updated so the banner + section disappear
without a server round-trip
Every new account now has both auth methods from day one — the
passkey signup form gains a passphrase field that wraps the same
master (= PRF result) and lands in the user row in the same
INSERT as the credential.
Existing passkey-only accounts get a top-of-app banner nudging
them into Settings → "enroll passphrase," which is the path that
already existed.
- src/lib/auth.ts registerPasskey: requires a passphrase, runs
Argon2id locally, ships wrappedMaster + salt + verifier +
argon2Params alongside the WebAuthn finish payload
- server/auth.ts RegisterFinishInput + finishRegistration:
requires + persists those fields in a single transaction
with the user/credential insert
- server/index.ts /api/auth/register/finish: validates the new
body shape
- Identity.hasPassphrase (server-computed via
passphrase_verifier IS NOT NULL) threaded through every
identity-returning path
- App.tsx AuthForm: shows the passphrase field in register +
passkey mode with a one-liner explaining why
- App.tsx PassphraseNudge banner: visible when
identity.hasPassphrase === false, opens the existing
Settings modal which already has the enrollment UI
The server now keeps `email_hash` (HMAC-SHA256 of the lowercased
typed email under a deployment-wide secret) and never persists
plaintext email. Lookup flow is unchanged for the user: type your
email at login, server HMACs it in-memory and queries by hash.
- new server/secret.ts: deployment HMAC key from
HEZO_EMAIL_HMAC_KEY env var, else generated and persisted to
`<HEZO_DATA>/email_hmac.key` on first boot
- new server/paths.ts: extracts DATA_DIR so secret.ts and
schemas/index.ts can both reach it without cycling through
db.ts
- migration framework: optional `apply(db)` JS step on a
Migration, plus a `useTransaction: false` opt-out for
migrations that need to toggle PRAGMA foreign_keys
- migration v5: rebuild users + webauthn_challenges via the
standard CREATE…/INSERT…/DROP…/RENAME dance (SQLite can't
DROP a UNIQUE column directly); existing emails get HMAC'd
in place; runs foreign_key_check at the end
- server/auth.ts: every email-shaped lookup, insert, and
challenge row uses email_hash; Identity returned to the
client carries only userId (no email)
- server/projects.ts: members API drops email too; clients
don't need it now that display names live in the CRDT
- shared schema: Identity.email becomes optional (client-only,
filled from localStorage); Member loses email entirely
- src/lib/auth.ts: after passkey/passphrase login the typed
email is stashed in localStorage and merged back onto the
Identity client-side; logout clears
- App.tsx: displayNameFor + ensureOwnDisplayName tolerate a
missing identity.email (fall back to short userId)
- README + DEPLOY: document the HMAC key and the "back this
file up or orphan every account" caveat
Display name is now per-project user data, encrypted client-side
and synced via the outbox — the server never sees it.
- migration v4: drop users.display_name
- server/auth.ts + projects.ts: strip display_name from all
queries and API responses; WebAuthn userDisplayName is now
computed inline from email at call-time (presentational only,
never stored)
- shared Identity loses displayName; Member keeps only
{ userId, email, role }
- new ProjectDoc.profiles: Record<userId, Profile> with a
displayName field; backfilled in loadDoc for older docs
- new ProjectStore.{setDisplayName, ensureOwnDisplayName}
- App: render member list + turn authors via displayNameFor()
helper (profile → email local-part → short userId fallback);
sidebar gains "appearing as <name> · edit" affordance;
seeds default on first project open per device
Lifts drag-and-drop from the FileSection sidebar up to the whole
project view via window-level listeners, with a full-viewport
"Drop files to upload" overlay while a drag is in progress. Drops
on a file row replace that file's contents in place (preserves id,
name, createdBy, createdAt); a Replace button in the FilePane does
the same via the file picker. Row drop calls
stopImmediatePropagation so the window handler doesn't also upload
the same file as a new entry.
New store method: replaceFileContent(id, { mime?, content?,
contentBytes? }).
FileSection grows an "Upload" button and accepts files dropped on
its sidebar region. Text files (md/txt/code/json/etc.) decode UTF-8
into the existing `content` field. PDFs land in two new optional
ProjectFile fields — `contentBytes: Uint8Array` and `mime` — and
render in the preview pane via an iframe over a blob URL.
Per-file cap is 1.5 MB so a whole-doc snapshot stays under the
outbox's 4 MB ciphertext limit. Edit tab is hidden for binary files;
they get a Download link instead.
A new joiner whose ProjectStore opened with no local doc would
createDoc() with its own root actor. If they made any local change
before adopting a peer snapshot, applyLocal would commit + broadcast
that empty/sparse doc to the project. On the recipient side, merging
that into a populated doc hit the same root-map LWW conflict that the
isFresh→adopt path was already there to prevent — only now the
populated peer was the loser, and their data was clobbered.
Two guards:
- applyLocal early-returns when isFresh; no mutation can land.
- broadcastSnapshot early-returns when isFresh; safety belt.
Project creators now seed local persistence with an empty doc right
after the server-side create, so ProjectStore.open() sees existing
state and clears isFresh — the creator IS the seeder and must be
able to write. Joiners (no local seed) stay isFresh until they adopt
a peer snapshot; UI shows a "Waiting for first sync" state.
Each device now persists a per-project watermark (highest createdAt
seen) and passes it on subscribe; server returns rows past that and
no longer deletes on ack. Outbox rows TTL out at 30d as before.
Fixes the multi-device race where two devices for the same user
shared a queue: whichever acked first deleted the row from under
the other. iOS in particular kept getting an empty project after
desktop's snapshot had already been ack-consumed by desktop itself.
Reset-local now also wipes the cursor so the next subscribe starts
from zero.
iOS browsers don't expose a per-origin "Clear data" — only a
nuke-everything path that wipes login state, IndexedDB, the whole
PWA cache. Adding an in-app reset just for the local CRDT copy
of one project so users can recover a corrupted-merge state
without losing keys / passkey credentials.
Reset:
- persistence.deleteDoc(projectId) wipes the IndexedDB entry
- Next open finds nothing, store goes through the isFresh path,
next peer snapshot is adopted wholesale.
Project itself isn't deleted server-side; other devices /
members are unaffected.
Bug: a device joining a project for the first time was creating a
local Automerge doc via Automerge.from(INITIAL) — fresh actor with
its own root operations (create the meta/files/conversations/turns/
bots maps). Merging that empty starter into a peer's populated doc
gave Automerge two concurrent assigns to each root field, and LWW
could pick the empty side, dropping the peer's data on the floor.
Worse, the merged-but-empty state could then be persisted and
broadcast back, propagating the loss.
Fix:
- crdt.ts: new adoptDoc(bytes) — Automerge.load + clone, so the
device shares the peer's root history but writes new changes
under its own actor.
- store.ts: ProjectStore tracks isFresh (true when there was no
IndexedDB entry at open). The first incoming snapshot on a
fresh store is adopted wholesale via adoptDoc rather than
merged. After adoption isFresh flips false and subsequent
snapshots merge normally (which is safe because the histories
now share a common root).
Existing populated-then-corrupted local docs aren't auto-healed
by this — they have to be wiped + re-seeded from a known-good
source (e.g. the push-project script).
`getRecipients` was reading membersRef.current at broadcast time. The
ProjectStore.open's auto-push fires right after subscribe, before the
async `/api/projects/:id/members` GET has resolved, so membersRef was
still []. The empty recipient list caused outbox.broadcast to no-op,
and the user's other devices never got the snapshot.
Now `getRecipients()` always includes the local user-id as a floor,
then adds any members that have loaded so far. The snapshot push
goes to (at least) the user's own user-id; server fanout reaches
every connected device of theirs.
The "container expanding past screen" + "page scrolls even with no
content below" symptoms both point to body itself growing past the
visible viewport on iOS. CSS-only constraints (max-width: 100vw,
overflow-x: hidden) weren't enough — iOS treated the body as
scrollable and rubber-banded.
- index.html: html / body get `overflow: hidden`. #root is
`position: fixed; inset: 0; width: 100dvw; height: 100dvh` —
literally bounded to the visible viewport. dvw/dvh respond to
the URL bar without growing the document.
- Add `input, textarea, select, button { min-width: 0 }` so iOS
form intrinsic widths can't push their flex/grid parents wider.
- Bump default input font-size to 16px so iOS Safari doesn't
auto-zoom on focus.
- Wrap AuthForm and ProjectList in a pageScrollerStyle div
(height/width 100%, overflowY auto) so their cards still scroll
on a short viewport even though the body itself can't.
This matches how the ConversationPane already scrolled — outer
shell is fixed, inner panes own their overflow.
Accounts predating the sync feature have keys only in IndexedDB
on the device where they were entered, with nothing on the
server. Without intervention, the other device's loadKeysSynced
sees null and shows an empty Settings panel.
loadKeysSynced now: when the server returns null AND the local
cache has at least one key, push the local set up. Next device
load gets the populated state.
Fire-and-forget (.catch -> warn); no blocking on network.
Sends issued before the WebSocket finished its handshake (or
during a reconnect) were silently dropped. That's why the
auto-snapshot-on-open could vanish when ProjectStore.open
called broadcastSnapshot while the socket was still negotiating
— iOS would then see nothing because no entry ever made it to
the server outbox.
Now: send() pushes to a `pending` queue if the socket isn't OPEN,
capped at 200 to avoid unbounded growth on a permanent outage.
The 'open' handler re-sends subscribes (existing behavior) and
then flushes the pending queue.
- SettingsModal: card is now max-height 90dvh with overflowY: auto,
so on a small screen the contents scroll inside the modal instead
of the top getting clipped while the page behind scrolls. Backdrop
gains overscroll-behavior: contain to keep iOS rubber-band from
passing through.
- Inputs: min-width: 0 so iOS Safari's intrinsic input min-width
doesn't push the auth/projects cards past the viewport.
- Root: html / body / #root pinned to width: 100% / max-width: 100vw /
overflow-x: hidden. Long URLs / tokens get overflow-wrap: anywhere
so an unbroken string in a <p> can't widen the card.
Two bugs reported on iOS:
1. Inside a project, the top bar / hamburger / drawer didn't show.
mainStyle used `height: 100%`, which is correct inside the
desktop Panel but pushed `<main>` to a full-viewport height in
the mobile flex column (next to the header), shoving everything
else off-screen. Introduce a separate mobileMainStyle with
`flex: 1` + `min-height: 0`. Mobile shell switches to `100dvh`
so the URL bar's shift doesn't lop content off.
2. Login + projects list pages widened past the device width.
cardStyle and the projects list outer div had no `width: 100%`
+ `box-sizing: border-box`, so their `maxWidth` could be
exceeded by inner content's natural width on narrow viewports.
Belt-and-braces on html/body with `width: 100%` +
`overflow-x: hidden`.
`@media (max-width: 767px)` switches the project view from the
desktop three-pane PanelGroup to a mobile shell:
- Top bar with hamburger, project name, settings (⚙)
- Main: just the conversation pane (full width)
- Tap hamburger → drawer slides over from the left with the full
sidebar (members, bots, files, conversations). Tapping any item
closes the drawer.
- Tapping a file opens FilePane as a full-screen overlay (z-index
60). × in the file header closes back to the conversation.
Extracted the sidebar inner content into <SidebarContent> so it
renders in either the desktop <aside> or the mobile drawer without
duplication. Pick-and-close handlers route through ProjectBody so
the same React state drives both layouts.
useIsMobile hook listens to a `(max-width: 767px)` matchMedia query
and triggers re-render on resize / orientation change.
Walking back the "per-device only" design for both provider keys
and project content. Each device still has a local IndexedDB cache;
server now also carries an encrypted blob (or fans out via outbox)
so opening on a second device gets you the same view.
Provider keys (Anthropic, OpenAI, etc.):
- schemas v3 migration: ALTER TABLE users ADD wrapped_provider_keys.
- server/auth.ts: getWrappedProviderKeys / setWrappedProviderKeys.
- server/index.ts: GET/PUT /api/me/keys (auth required, opaque blob).
- lib/crypto.ts: deriveProviderKeysWrapKey (domain-separated HKDF from
the master secret), plus reusable aesGcmEncrypt/Decrypt helpers.
- lib/keystore.ts: loadKeysSynced + saveKeysSynced. Local IDB stays
as the offline cache; server is authoritative when reachable.
- App.tsx: useEffect pulls from server on auth, push on every save.
Project state across user's own devices:
- ProjectStore.getRecipients now includes self. Outbox fans out to
every connection of the user's id, so the other devices subscribed
to the project receive the snapshot. Sender's own connection
receives back; merge is a no-op since heads match.
- ProjectStore.open: if local doc has any heads, push a fresh
snapshot through the outbox right after subscribe. That way a
brand-new device that comes online later picks up the snapshot
from the outbox queue without needing the source device to mutate
again.
Two bugs:
1. mainStyle used `flex: 1` from when `<main>` lived inside a flex
row in ProjectBody. Phase 5 wrapped main in a `<Panel>`, where
`flex: 1` no longer gives it a height — so the inner scroller
couldn't compute a positive scroll viewport and was stuck. Switch
to `height: 100%` to fill the Panel.
2. Snap-to-latest didn't always fire on conversation open because
the existing useEffect's deps (lastTurn id/length, turns.length)
don't change if you reopen the same conversation. Add a conv-id
effect that forces stick + scrolls to bottom, with a 50ms retry
to catch reflows from markdown rendering.
Adds an Argon2id-derived passphrase as a second login method that
produces the same account key as the user's passkey. Designed for
clients/authenticators that don't expose WebAuthn PRF — most notably
iOS via Proton Pass / Bitwarden / etc., where PRF isn't yet plumbed
through the iOS Credential Provider.
Architecture:
- One "master secret" per account: for passkey users this is the PRF
output (unchanged); for passphrase-only signups it's random 32 bytes.
- Argon2id(passphrase, salt, params) → 64 bytes split into a 32-byte
AES-GCM wrap key (in-browser only) and a 32-byte verifier (sent
server-side for login comparison via timingSafeEqual).
- Server stores wrapped_master + salt + verifier + argon2 params on
the user row. Server never sees the wrap key or the master.
Server:
- schemas/index.ts v2 migration: ALTER TABLE users to add the four
new columns (nullable; existing passkey-only rows leave them blank).
- auth.ts: passphraseLoginBegin / passphraseLoginFinish /
passphraseRegister / passphraseSetup. Login uses a dummy salt for
unknown emails so timing doesn't trivially leak account existence.
- index.ts: POST /api/auth/passphrase/{login/begin,login/finish,
register,setup}.
Client:
- crypto.ts: deriveFromPassphrase via @noble/hashes argon2id. Helpers
for random salt + master secret.
- lib/auth.ts: registerPassphrase / loginPassphrase / setupPassphrase.
Both passkey and passphrase paths return the same AuthResult shape
(identity + 32-byte secret), so downstream code (project key wrap,
identity_pub derivation) is unchanged.
- App.tsx AuthForm: method toggle (passkey ↔ passphrase). Below the
submit button, "Passkey doesn't work? Use passphrase instead" /
"← Back to passkey".
- App.tsx SettingsModal: "Passphrase backup" section. Sign in via
passkey on desktop (PRF works), enroll a passphrase from Settings,
then iOS can sign in via email + passphrase.
Defaults: argon2id m=32MiB, t=2, p=1 in browser. ~0.5–1s per derive
on a modern device.
The earlier message blamed only the browser, but PRF support is
joint: both the browser/UA AND the passkey provider need to
support it. iCloud Keychain, 1Password, Bitwarden support PRF on
iOS. Some third-party iOS Credential Providers (Proton Pass etc.)
don't yet expose PRF through iOS — same passkey works on their
desktop extension but fails on the iOS app. Mention this in the
iOS path so users know to try iCloud Keychain.
On iOS, only Safari 18+ exposes the WebAuthn PRF extension; Firefox
iOS / Orion / Chrome iOS all use WKWebView, which doesn't expose
PRF to JS. The error now detects iOS and points the user to Safari
specifically; non-iOS callers get a list of supported desktop
browser versions. (Real fix is a passphrase-based fallback wrap
key for browsers without PRF — out of scope for now.)
- TurnRow body now renders via ReactMarkdown (remark-gfm), so
headings, code blocks, lists, tables etc. show up like the file
preview already does. Mention chips still work — a small
processMentions helper walks ReactMarkdown's children and runs
the existing `renderWithMentions` over any string children at
paragraph or list-item scope.
- App: stash the PRF secret in sessionStorage on auth, restore on
boot. A page reload no longer kicks the user back to the
passkey re-auth prompt; closing the tab still clears it. Same
trust boundary as the rest of v2's prototype shortcuts —
anything with XSS on hezo.app could read it, but so could the
React heap.
- Layout: wrap sidebar / main / file pane in react-resizable-panels
with PanelResizeHandle bars between them. autoSaveId persists the
user's chosen widths in localStorage. Sidebar/file-pane styles
drop fixed widths so the Panel owns sizing.
- Markdown: file preview was rendering as a `<pre>` of raw markdown.
Swap in react-markdown + remark-gfm, scoped to a `.hz-md` class.
- index.html: add `.hz-md` typography (headings, code, blockquotes,
tables) tuned to the existing theme tokens, and a `.hz-resize`
class for the panel handles.
- Files sidebar: `×` delete button per row (matches the
Conversations row), confirmation prompt. Closes the file pane if
the currently-open file is the one deleted.
Phase 4 dropped the file pane when ProjectView was rewritten into
sidebar+main; the CRDT still carries files (the Greece migration
imported main.md + summary.md) but they were invisible. Bringing
back a third pane.
- src/lib/crdt.ts: add ProjectFile type + files field to ProjectDoc.
Backfill files: {} in loadDoc for older docs. Add fileList selector.
- src/lib/store.ts: createFile / updateFileContent / renameFile /
deleteFile mutators (regular applyLocal flow, broadcasts a CRDT
snapshot to peers).
- src/App.tsx: FileSection in sidebar (above Conversations). FilePane
on the right when a file is selected, with preview / edit toggle
+ rename-on-click on the title.
The v1 push-project pointed at a server endpoint that no longer
exists. The v2 path can't send content to the server, but it can
ship content to a logged-in browser via a URL fragment, same trust
model as invite links: K_proj plaintext + Automerge doc bytes ride
in `#import-project=`. The fragment never leaves the local
browser, gets scrubbed on load, gets wrapped and POST'd to
/api/projects after auth.
- scripts/push-project.mjs: rewritten to build an Automerge doc
from a local v1 SQLite (conversations + turns + text files;
binary files skipped — v2 hasn't wired blob transport yet) and
open the import URL.
- src/main.tsx: handle `#import-project=` by stashing the raw
payload in sessionStorage and scrubbing the hash, then mount.
- src/App.tsx: after auth, consume the stash. Derive wrap key from
PRF, wrap K_proj, POST /api/projects to mint the row, persist
the Automerge doc bytes under the server-assigned project id.
Cleared on success or alert-and-clear on failure.