Repository navigation
Leafdesk 0.5.4
Upgrading from 0.5.3
- Migrations (0070–0077) run automatically when the container starts. Compose users
don't need a newdocker-compose.yml: this release adds no settings. - Every passage is embedded again once. Passages now start with where they are (see
Changed), so their text changes and the embeddings model sees each of them once more after
the upgrade: through the background sweep as people search, or all at once with
pnpm search:index. On large workspaces with a paid embeddings provider, run it at a quiet time. - Migrations 0075 and 0077 each rewrite the
pagetable once. 0075 adds the stored search
vector (see Changed) and fills it for every page, 0077 adds the pieces of Chinese and Japanese
text to it and keeps it to the first 80,000 characters of each page's text; on a server with
many pages each takes a while (about 15 seconds for 60,000 pages with 2 KB of text each), and
pages can't be read or written until it's done: upgrade at a quiet time (from source, run
pnpm db:migratethen).
Added
-
Eleven more interface languages. Leafdesk now speaks Italian, Brazilian Portuguese, Polish,
Russian, Ukrainian, Arabic, Hebrew, Persian, Japanese, Korean and Simplified Chinese, next to
English, Turkish, German, Spanish and French: in the app, emails, built-in templates and the
editor's menus, picked in My account → Language or taken from the browser. Arabic, Hebrew and
Persian lay the whole app out right to left, and emails follow. Each paragraph of a page now
follows the direction of its own text, so a right-to-left paragraph reads correctly in a
left-to-right interface and the other way round; alignment runs with it too, so a right-to-left
paragraph starts on the right without being aligned and the align buttons still put it on the side
they show. An Arabic, Hebrew or Persian paragraph aligned right before this release now sits on
the left, where its text ends: align it again. CSV import knows the new languages' title column
names and yes/no words, quick add understands their date words (Japanese and Chinese, written
without spaces, a character at a time: "来週の金曜日に打ち合わせ"), and the AI chat's search leaves out their
question words. The new translations haven't been reviewed by native speakers yet; corrections are
welcome. -
Apps can't overwrite edits they haven't seen.
get_page(MCP) andGET /pages/{id}now give
the body'sversion, andupdate_page/PATCH /pages/{id}take it back asexpected_version:
the page changes only while its body still has that version. When someone edited it after the
app read it, also in an editor that is open right now, nothing is written; MCP tells the app to
read the page again, and the REST API answers 409version_conflictwith the current version.
Body writes report the newversion, so an app can make several changes in a row without reading
the page in between. Comments don't change the version. Withoutexpected_versionwrites work
as before. Covered inscripts/mcp-e2e.ts. -
The AI chat answers from attached PDFs and Word documents. The text of a PDF or .docx shown
in a page's file block now goes into the page's semantic index, in passages that start with the file name and point
at the file block, so the chat can answer from inside the document and its
citation opens the page at the file. A document is read once and its text kept (migration
0071,file_text); the same file uploaded again isn't read twice. Only the first 50 pages or
200,000 characters of a document are read (the record says when it was cut), up to 20 documents
per page, and files over 25 MB are left out; a scan with no text layer, or a damaged or locked
file, adds nothing. Only files the page's readers may open through the page count, and search
checks access to the page when it runs, as for the rest of its text. Existing pages are indexed
again by the next sweep orpnpm search:index; their unchanged passages aren't embedded again. -
Workspace health. Settings → Health (owners and members) looks over the pages you can open
and lists what is worth tidying: links and mentions to pages in the trash or deleted for good,
top-level pages nothing links to, pages with a title and no body, pages nobody edited for 3, 6,
12 or 24 months, pairs of pages that say nearly the same thing, and database relations to rows in
the trash or deleted. Duplicates are compared by the pages' embeddings, so they need an
embeddings model on the server and AI on in the workspace; otherwise the report says they
weren't checked. Each finding links to its page, and many have a one-click fix: restore the page
a link leads to, link an orphan where another page already writes its title, move an empty page
to the trash, or take a dead row out of a relation. Pages someone can't open are never named or
counted, not even as a broken link. The same report is the new MCP toolcheck_workspace, which
changes nothing and says which tools fix each finding. Covered in
scripts/workspace-check-e2e.ts. -
More filter conditions. Text, URL, email, phone and title filters (and formulas and rollups
with a text result) gain Does not contain, which ignores case as Contains does and matches
empty values. Numbers gain ≥ and ≤, and dates, created and edited times Is on or before and
Is on or after, which match a date range when any of its days does, as Is before and Is
after do. Selects and statuses gain Is any of and Is none of, and tags, relations and
people Contains any of and Contains none of: one rule with several values, where "Me" counts
for whoever looks. They work the same in the app, on published pages, in dashboards, for
restricted properties and through the API, MCP (query_database, saved views) and the AI chat,
asdoes_not_contain,gte,lte,is_any_ofandis_none_of(the last two with a list of
values). Saved views keep working unchanged. -
AI changes to a page can be kept or undone one by one. After an AI app, the chat or an agent
writes to a page body, the page shows a bar above the body with how many blocks changed and
who changed them: Review opens the changes block by block with Keep and Undo for each,
next to Keep all and Undo all. Undoing goes through the page's shared document, so editors
open on the page follow at once, and Undo all saves a version first. Writes in a row are
reviewed together; the bar goes away once every change is decided or someone edits the page.
Only people who may edit the page see it (migration 0072). -
Changing one block instead of the whole body. The chat and agents have a new
edit_page
tool that replaces, inserts after, appends or deletes blocks by id, andupdate_page(MCP) and
PATCH /pages/{id}take the modesreplace_block,insert_afteranddelete_blockwith a
block_id.get_page/GET /pages/{id}give the ids withwith_block_ids. Like every AI
write, these save the page first and can be reviewed on the page. Agents may useedit_pageon
the rows they already may update. -
Approval cards show what changes. Before the chat changes a page's text, the approval card
shows the blocks it changes, adds and removes; for a row it shows each property's old value
next to the new one, and the old title when it is renamed. Covered inscripts/ai-chat-e2e.ts
andscripts/mcp-e2e.ts. -
Table cells work from the keyboard and the clipboard. Click a cell (or move with the arrow
keys) to make it the active cell; Shift with an arrow, Shift+click or a drag selects a range.
Enter opens the active cell's editor and Esc brings you back to the cell, Delete clears the
range. Copy puts the range on the clipboard as tab-separated text, the way exports write values,
and pasting cells copied from a spreadsheet writes them from the active cell on: each value is
read as the CSV import reads it (numbers like1.234,5or15%, dates, yes/no, option and
people names), options a select lacks are added, and rows past the last one are added in an
ungrouped table. One pasted value fills the whole selected range. Cells that don't fit their
column or that you may not change are left out and counted. -
Row activity tells AI values apart. A value AI autofill filled in now reads as the AI's in
the row's activity, naming who it was worked out for: "AI (for Ada) set Category to Billing",
with a sparkle in place of a face. That person is whoever refreshed the row or updated the
view's rows, or whose change of the row set the value off; for an automation's change, the
person it acts as, its own entry for the change sitting right below. AI entries never merge into
that person's own edits of the value.list_row_activity(MCP) andGET /rows/{id}/activity
giveactor: "ai", with the person inby. Needs migration 0074. -
Agents read every row and write rows safely.
query_database(MCP andPOST /databases/{id}/query) now returnsnext_cursorandhas_more: pass the cursor back with the
same filters and sorts to read the next page, until it is null. Pages follow on from the last row
returned, so rows added or removed between calls neither repeat nor go missing; a cursor from
another query is refused.propertiesnarrows each row to the values asked for.
create_database_row(s)takematch_by(the title or a text, number, email, phone, URL, select
or status property) to upsert: a row with the same value is updated instead of a new one being
added, so a retried import adds nothing twice, and each row says whether it wascreatedor
updated. A value two given rows share, or that several existing rows hold, is refused before
anything is written.update_database_rowstakesexpect(refuses, writing nothing, unless
exactly that many rows would change) anddry_run. Covered inscripts/row-safety-e2e.tsand
scripts/mcp-e2e.ts. -
Published pages can show databases from elsewhere. A published page shows the databases that
lie under it; a dashboard or database view over a database elsewhere used to stay empty for
visitors. The Publish tab now lists those databases, before and after publishing: tick Show on
this page to show one there anyway, or move it under the page to publish it with it. A database
shown this way appears only in the page's widgets and views, without links to it or its rows, and
Duplicate leaves it out. It is hidden again as soon as the publisher can no longer see it or it
goes to the trash (migration 0076).
Changed
- Search stays fast in large workspaces. Full-text search (the search box, MCP and REST
search, the AI chat, agents) now matches and ranks pages through an index of a vector Postgres
keeps for each page (migration 0075,page.search_vector: the title's words weighted above the
body's), and checks access only for the best pages until there are enough. The vector holds the
first 1,000 characters of a title and the first 80,000 of a body (migration 0077), so a page of
a few megabytes of distinct words still saves: words past that point of a huge page aren't
found by full-text search. Before, every search
read the text of every page in the workspace: in a test workspace of 60,000 pages a search for a
rare word took 3.7 seconds and now takes under 60 ms, one for a word most pages have went from
3.6 seconds to 0.4. A word now also matches as the start of a longer word ("kond" finds
"kondenser"), in the body as in the title, so pages show up while a word is typed; the middle of
a word no longer matches. Long pages no longer rank above short ones only for their length. The
list of pages that write a page's title without linking to it uses the same index. Covered in
scripts/search-e2e.ts. - Japanese and Chinese words are found inside sentences, Korean ones with a particle joined
on. Chinese and Japanese write no spaces between words, so search took a whole sentence as one
word and found a page only by its first characters: "会議" didn't find "明日の会議について". The
search vector now also keeps every two-character piece of such text (migration 0077,
cjk_bigrams, for the first 30,000 such characters within the first 80,000 of a page), and
queries are cut the same way: a word is found anywhere, a single character
as it is typed, and the pieces of a word only side by side ("東京都" doesn't find a page about
"東京" and "京都"). Korean is written with spaces, so its words are still found by their start
("회의" finds "회의를", not "국회의원"). The AI chat's any-word search takes the words of such a
question without particles and function words ("车在哪里" looks for "车"), and Korean words
without a particle at their end. The list of pages that write a page's title without linking to
it finds such titles inside sentences too, but not inside a longer word ("会議" isn't offered in
"国会議員", "メモ" not in "メモリー"), so linking one never cuts a word apart. Covered in
scripts/search-e2e.tsandscripts/backlinks-e2e.ts. - The AI chat gets the matching passage without an embeddings model too. A page the chat's
search finds by its words now comes with the part of its text that has the most of them, about
900 characters, where the model used to get one line around the first word unless the page was
in the semantic index. Covered inscripts/ai-chat-e2e.tsandsrc/server/ai-tools.test.ts. - Semantic search knows where a passage is. Each indexed passage now carries the titles of
the pages above its page, the page's own title and the heading it lies under (at most 200
characters; a database row's path includes its database), so a passage under Sales › 2026 plan
› Risks is found for questions about sales risks even when it never says "sales". A heading now
starts a new passage. Renaming or moving a page indexes the pages under it again. Results and
the AI chat show the passage without that path, since it can name pages the reader can't open.
Pages are indexed again after upgrading (the chunking version went up). - The AI chat cites the right block for pages found by their words. A page that only the
full-text search found reached the model as a short snippet without a block, so its answer
cited the whole page and it often read the page again. It now gets the page's indexed passage
with the most of the question's words, and the citation links to that block. - Long tables render only the rows in view. From 120 rows on, a table view renders the rows
near the visible part of the page and stands in for the rest, so tables with thousands of rows
open and scroll without lag. Grouped tables, sub-items, frozen columns and group calculations
work as before.
Fixed
-
A page that can't be saved says so. When the database refused to save a page (as an
oversized search index once did), the failure only reached the server log: creating a page with
a body, the REST API, AI apps and imports went on as if the body had been written, and the editor
kept showing the page as live. Such writes now fail with an error (500internal_errorfrom the
REST API, an error result for AI apps), and the editor's status says "Not saved" until a later
save goes through. A page created with a body that couldn't be saved stays, without the body. -
AI autofill values count as row changes. A value AI filled in was saved past everything a
row write sets off: semantic search didn't index it until something else changed the row, the
row's activity didn't show it, and automations waiting for the property to change never ran.
Values are now saved like any other change (for the person who asked for them), and only when
they differ from the stored one. They start automations, except when an automation's change set
them off: those count as the automation's own changes, which start none, so automations and AI
values can't keep setting each other off. After an AI value is written, only the automatic AI
values that read it update, and none twice in a row of updates.- Other AI values, and formulas over them, go into an AI property's prompt only when its settings
name them (a{Property}placeholder, the translated property); summaries no longer take them
in on their own. Values whose prompt changes this way are worked out again the next time their
row changes. - Settings that would make an AI property read itself through other AI properties are refused
with a message naming the property in the way. Covered inscripts/ai-e2e.ts.
- Other AI values, and formulas over them, go into an AI property's prompt only when its settings
-
Semantic search catches up when the way pages are cut into chunks changes. The index
noticed pages that changed and a new embeddings model, but not a release that chunks pages
differently: pages kept their old chunks until someone edited them, andpnpm search:index
skipped them. The index now records the chunking it was built with (migration 0070), and pages
built with another one count as stale for the sweep andpnpm search:index. Chunks whose text
stays the same aren't embedded again. Covered inscripts/semantic-search-e2e.ts. -
Edits of different properties of one row no longer undo each other. Editing a row's value
wrote back the whole row as it was read a moment before, so a value someone else, an agent or
an automation had just set in another property of the same row could quietly disappear. Single
row edits now merge into the stored values the way bulk edits already did. Covered in
scripts/database-e2e.ts. -
Stopping the server no longer loses the last edits. On SIGINT/SIGTERM the server started the
saves still waiting out their debounce but exited half a second later whether they had finished
or not, so on a large page or a slow database up to the last ten seconds of typing could be lost.
It now closes the editors' connections, waits until every open page is saved (at most eight
seconds, inside Docker's ten-second grace period) and logs it when it had to stop waiting. A
second Ctrl+C exits at once. -
Deleted files no longer stay behind in storage. Deleting a workspace, an account or orphaned
uploads removed the files' records first and their bytes afterwards, at best once: when storage
refused or the server stopped midway, the bytes stayed with nothing pointing at them. The objects
to delete are now queued in the same transaction as the records (migration 0073), and what
storage refuses is tried again by the hourly file cleanup, waiting longer after each failure (up
to a day). Covered inscripts/storage-deletion-e2e.ts.