From 9bc17289e5532c50d03f74c724d750204dd054da Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 14:17:06 -0300 Subject: [PATCH 1/4] feat: an agent can say a document has had its day, and the archive keeps where it came from flag_doc marks a document an assistant found is no longer worth keeping, with the why; archiving it, deleting it and taking the mark off stay the person's, from the window. A document put away on its own now hangs from the folders it came from inside the archive, and unarchiving asks where it lands. The Documents heading stays at the edge of the scroll, so the archive below it is never read as the other Archive. --- app/src-tauri/src/lib.rs | 40 ++++++- app/src/App.tsx | 145 +++++++++++++++++++++++++- app/src/core.ts | 11 +- app/src/locales.ts | 40 +++++++ app/src/tests/lifecycle.test.tsx | 113 +++++++++++++++++++- app/src/ui/Docs.tsx | 57 +++++++++- app/src/ui/Sidebar.tsx | 2 +- app/src/ui/Tree.tsx | 95 ++++++++++++++++- crates/tisty-cli/src/main.rs | 2 + crates/tisty-cli/src/mcp.rs | 93 +++++++++++++++++ crates/tisty-cli/tests/mcp.rs | 87 ++++++++++++++++ crates/tisty-core/src/event.rs | 8 +- crates/tisty-core/src/event/op.rs | 55 +++++++++- crates/tisty-core/src/model.rs | 2 +- crates/tisty-core/src/model/folder.rs | 15 +++ crates/tisty-core/src/state.rs | 109 ++++++++++++++++++- crates/tisty-core/src/tagging.rs | 2 + crates/tisty-core/src/tidy.rs | 2 + crates/tisty-core/src/undo.rs | 6 +- crates/tisty-core/tests/roundtrip.rs | 2 + crates/tisty-core/tests/telling.rs | 18 ++++ 21 files changed, 884 insertions(+), 20 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index a3ac9dd6..e5e2c13b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3088,6 +3088,19 @@ struct Filed { #[serde(skip_serializing_if = "Option::is_none")] guest: Option, page_of: Option, + #[serde(skip_serializing_if = "Option::is_none")] + flagged: Option, + #[serde(skip_serializing_if = "Option::is_none")] + folder_was: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct Marked { + at: String, + said: String, + #[serde(skip_serializing_if = "Option::is_none")] + via: Option, } const CATCHING_UP_AT_ONCE: usize = 500; @@ -3188,6 +3201,12 @@ fn gathered(session: &Session) -> Vec { guest: kept.guest.then(|| kept.by.clone().unwrap_or_default()), page_of: kept.page_of.map(|up| up.to_string()), tags: kept.tags.iter().map(|one| one.to_string()).collect(), + flagged: kept.flagged.as_ref().map(|mark| Marked { + at: mark.at.to_string(), + said: mark.body.clone(), + via: mark.via.clone(), + }), + folder_was: kept.folder_was.clone(), }) .collect() } @@ -3265,7 +3284,7 @@ fn folder_add( parent: Option, icon: Option, color: Option, -) -> Answer<()> { +) -> Answer { let name = named_folder(&name)?; let parent = parent .map(|at| at.parse().map_err(|_| Refusal::of("noSuchFolder"))) @@ -3296,8 +3315,9 @@ fn folder_add( .iter() .map(|one| one.order.as_str()), ); + let id = ulid::Ulid::generate(); session.commit(Op::FolderAdd { - id: ulid::Ulid::generate(), + id, d: tisty_core::event::FolderAdd { name, order, @@ -3306,7 +3326,7 @@ fn folder_add( color: painted, }, })?; - Ok(()) + Ok(id.to_string()) } #[tauri::command] @@ -4283,6 +4303,19 @@ fn doc_away(session: tauri::State<'_, Mutex>, id: String, away: bool) - Ok(()) } +#[tauri::command] +fn doc_unflag(session: tauri::State<'_, Mutex>, id: String) -> Answer<()> { + let id = id.parse().map_err(|_| Refusal::of("noSuchDoc"))?; + let mut session = held(&session); + match session.state.docs.get(&id) { + None => return Err(Refusal::of("noSuchDoc")), + Some(one) if one.flagged.is_none() => return Ok(()), + Some(_) => {} + } + session.commit(Op::DocUnflag { id })?; + Ok(()) +} + #[tauri::command] fn folder_away(session: tauri::State<'_, Mutex>, id: String, away: bool) -> Answer<()> { let id = id.parse().map_err(|_| Refusal::of("noSuchFolder"))?; @@ -7354,6 +7387,7 @@ pub fn run() { doc_let_go, retire_attachments, doc_away, + doc_unflag, folder_away, doc_lock, parted, diff --git a/app/src/App.tsx b/app/src/App.tsx index be053738..6f543f49 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -236,6 +236,8 @@ export default function App() { const [whoFor, setWhoFor] = useState(null); const [movingTo, setMovingTo] = useState(null); const [locked, setLocked] = useState(null); + const [backing, setBacking] = useState(null); + const [backTo, setBackTo] = useState("same"); const [number, setNumber] = useState(""); const [wrong, setWrong] = useState(false); const [menu, setMenu] = useState<{ @@ -477,6 +479,33 @@ export default function App() { }) .catch((e) => setError(saidPlainly(e))); + const bringBack = (doc: Filed) => { + setBackTo("same"); + setBacking(doc); + }; + + const backFrom = (doc: Filed): string | null => + doc.folder && papers.folders.some((one) => one.id === doc.folder) + ? trail(doc.folder) + : (doc.folderWas ?? null); + + const putBack = () => { + const doc = backing; + if (!doc) return; + setBacking(null); + const home = + doc.folder && papers.folders.some((one) => one.id === doc.folder) ? doc.folder : null; + const made = + backTo === "same" && !home && doc.folderWas + ? folderAdd(doc.folderWas) + : Promise.resolve(backTo === "same" ? home : backTo === "none" ? null : backTo); + docAway(doc.id, false) + .then(() => made) + .then((folder) => (folder === home ? undefined : docFile(doc.id, folder ?? undefined))) + .then(papersChanged) + .catch((e) => setError(saidPlainly(e))); + }; + const deep = (at: string | null | undefined): number => { let steps = 0; const seen = new Set(); @@ -1200,10 +1229,12 @@ export default function App() { label: doc.archived ? t("bringBack") : t("putAway"), off: !!doc.pageOf || byFolder(doc), apart: true, - onPick: () => - docAway(doc.id, !doc.archived) + onPick: () => { + if (doc.archived) return bringBack(doc); + docAway(doc.id, true) .then(papersChanged) - .catch((e) => setError(saidPlainly(e))), + .catch((e) => setError(saidPlainly(e))); + }, }, { key: "drop", @@ -1264,6 +1295,81 @@ export default function App() {
+ {backing !== null && ( + setBacking(null)} + > +

+ {backFrom(backing) === null + ? t("backFromNowhere") + : fill( + backing.folder && papers.folders.some((one) => one.id === backing.folder) + ? "backFrom" + : "backFromGone", + backFrom(backing) as string, + )} +

+
+ one.id === backing.folder) + ? "backToSame" + : "backToMade", + backFrom(backing) as string, + ) + } + hint={backFrom(backing) === null ? t("backWasHere") : undefined} + /> + {backFrom(backing) !== null && ( + + )} + {papers.folders + .filter((one) => !one.away && one.id !== backing.folder) + .map((one) => ( + + ))} +
+
+ + +
+
+ )} + {whoFor !== null && ( setWhoFor(null)}>

{t("packWhoWhy")}

@@ -1706,6 +1812,8 @@ export default function App() { .then(papersChanged) .catch((e) => setError(saidPlainly(e))) } + onDrop={dropDoc} + onBack={bringBack} fresh={carried} /> ) : chosen.named === "lists" && !chosen.list ? ( @@ -2151,6 +2259,37 @@ export default function App() { ); } +function Where({ + name, + value, + chosen, + label, + hint, + onPick, +}: { + name: string; + value: string; + chosen: string; + label: string; + hint?: string; + onPick: (value: string) => void; +}) { + return ( + + ); +} + const sliceWord = (slice: Slice) => slice === "today" ? ("today" as const) diff --git a/app/src/core.ts b/app/src/core.ts index 47af99d6..18918bf5 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -766,6 +766,12 @@ export interface Folded { export type Paper = "a4" | "letter" | "tabloid"; +export interface Marked { + at: string; + said: string; + via?: string | null; +} + export interface Filed { id: string; file: string; @@ -783,6 +789,8 @@ export interface Filed { gone?: boolean; guest?: string | null; tags?: string[]; + flagged?: Marked | null; + folderWas?: string | null; } export interface Papers { @@ -822,7 +830,7 @@ export const folderAdd = ( parent?: string, icon?: string, color?: string, -): Promise => invoke("folder_add", { name, parent, icon, color }); +): Promise => invoke("folder_add", { name, parent, icon, color }); export const folderRename = (id: string, name: string): Promise => invoke("folder_rename", { id, name }); export const folderLook = (id: string, icon?: string, color?: string): Promise => @@ -848,6 +856,7 @@ export const folderAway = (id: string, away: boolean): Promise => invoke("folder_away", { id, away }); export const docLock = (id: string, shut: boolean): Promise => invoke("doc_lock", { id, shut }); +export const docUnflag = (id: string): Promise => invoke("doc_unflag", { id }); export const docCopy = (id: string): Promise => invoke("doc_copy", { id }); diff --git a/app/src/locales.ts b/app/src/locales.ts index 6925d415..7740c7b4 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -549,6 +549,26 @@ const en = { unlockIt: "Unlock it", bringBack: "Unarchive", deleteIt: "Delete", + docFlagged: "An agent says this has had its day", + docFlaggedBy: "{name} says this has had its day", + unflagIt: "Keep it", + flagGone: "Mark taken off", + backWhere: "Unarchive «{name}»", + backFrom: "It came out of {name}.", + backFromGone: "It came out of {name}. That folder is no longer here.", + backFromNowhere: "It was in no folder.", + backToSame: "Back to {name}", + backToMade: "Make {name} again", + backToNone: "No folder", + backToOther: "Another folder", + backWasHere: "where it was", + backAtRoot: "outside every folder", + cameFrom: "from {name}", + folderTrace: "path", + folderTraceLost: "no longer here", + folderTraceWhy: + "The folder is still there, outside the archive. This only says where the document came from.", + folderTraceGone: "This folder was deleted while the document waited here.", folderActions: "Folder options", docActions: "Document options", docsActions: "Document section options", @@ -1855,6 +1875,26 @@ const es: Catalog = { unlockIt: "Desbloquear", bringBack: "Desarchivar", deleteIt: "Eliminar", + docFlagged: "Un agente lo da por viejo", + docFlaggedBy: "{name} lo da por viejo", + unflagIt: "Conservarlo", + flagGone: "Marca quitada", + backWhere: "Desarchivar «{name}»", + backFrom: "Salió de {name}.", + backFromGone: "Salió de {name}. Esa carpeta ya no está.", + backFromNowhere: "No estaba en ninguna carpeta.", + backToSame: "Volver a {name}", + backToMade: "Crear {name} otra vez", + backToNone: "Sin carpeta", + backToOther: "Otra carpeta", + backWasHere: "donde estaba", + backAtRoot: "fuera de toda carpeta", + cameFrom: "de {name}", + folderTrace: "camino", + folderTraceLost: "ya no está", + folderTraceWhy: + "La carpeta sigue ahí, fuera del archivo. Esto solo dice de dónde salió el documento.", + folderTraceGone: "Esta carpeta se borró mientras el documento esperaba aquí.", folderActions: "Opciones de la carpeta", docActions: "Opciones del documento", docsActions: "Opciones de documentos", diff --git a/app/src/tests/lifecycle.test.tsx b/app/src/tests/lifecycle.test.tsx index bdb7cc18..4f1d0dd3 100644 --- a/app/src/tests/lifecycle.test.tsx +++ b/app/src/tests/lifecycle.test.tsx @@ -23,6 +23,8 @@ interface FakeDoc { folder: string | null; archived: boolean; pageOf?: string; + flagged?: { at: string; said: string; via?: string }; + folderWas?: string; } const store = vi.hoisted(() => ({ @@ -223,6 +225,11 @@ function backend(cmd: string, args: Record): Promise { if (doc) doc.archived = Boolean(args.away); return Promise.resolve(null); } + case "doc_unflag": { + const doc = store.docs.find((one) => one.id === args.id); + if (doc) doc.flagged = undefined; + return Promise.resolve(null); + } case "folder_away": { const folder = store.folders.find((one) => one.id === args.id); if (folder) folder.archived = Boolean(args.away); @@ -311,7 +318,7 @@ function backend(cmd: string, args: Record): Promise { icon: (args.icon as string | undefined) ?? null, color: (args.color as string | undefined) ?? null, }); - return Promise.resolve(null); + return Promise.resolve(id); } default: return Promise.resolve(null); @@ -389,6 +396,11 @@ async function chooseFor(rowLabel: string, itemLabel: string) { await userEvent.click(await screen.findByRole("menuitem", { name: itemLabel })); } +async function backHome() { + const box = await screen.findByRole("dialog"); + await userEvent.click(within(box).getByRole("button", { name: t("bringBack") })); +} + async function moveTo(rowLabel: string, destination: string) { fireEvent.contextMenu(menuFor(rowLabel), { clientX: 5, clientY: 5 }); await userEvent.click(await screen.findByRole("menuitem", { name: t("moveTo") })); @@ -466,6 +478,7 @@ describe("archiving and bringing back a document", () => { expect(countBadge("Work")).toBe(""); await chooseFor("Report", t("bringBack")); + await backHome(); await waitFor(() => expect(countBadge("Work")).toBe("1")); expect( @@ -473,6 +486,104 @@ describe("archiving and bringing back a document", () => { ).toBeTruthy(); expect(screen.queryByRole("list", { name: t("archived") })).toBeNull(); }); + + it("asks where an unarchived document goes, and takes the answer over where it was", async () => { + const folder = seedFolder({ name: "Work" }); + const doc = seedDoc({ title: "Report", folder: folder.id, archived: true }); + await boot(); + + await chooseFor("Report", t("bringBack")); + await userEvent.click( + await screen.findByRole("radio", { name: new RegExp(`^${t("backToNone")}`) }), + ); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: t("bringBack") }), + ); + + await waitFor(() => expect(store.docs.find((one) => one.id === doc.id)?.folder).toBeNull()); + expect(countBadge("Work")).toBe(""); + }); + + it("keeps the folders a lone archived document hung from, inside the archive", async () => { + const folder = seedFolder({ name: "Work" }); + seedDoc({ title: "Report", folder: folder.id, archived: true }); + await boot(); + + const shelf = within(screen.getByRole("list", { name: t("archived") })); + expect(shelf.getByText("Work")).toBeTruthy(); + expect(shelf.getByText(t("folderTrace"))).toBeTruthy(); + expect(screen.getByRole("button", { name: "Work" })).toBeTruthy(); + }); + + it("offers to make the folder again when it went while the document waited", async () => { + const doc = seedDoc({ title: "Report", archived: true, folderWas: "Packaging" }); + await boot(); + + const shelf = within(screen.getByRole("list", { name: t("archived") })); + expect(shelf.getByText("Packaging")).toBeTruthy(); + expect(shelf.getByText(t("folderTraceLost"))).toBeTruthy(); + + await chooseFor("Report", t("bringBack")); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: t("bringBack") }), + ); + + await waitFor(() => expect(store.folders.some((one) => one.name === "Packaging")).toBe(true)); + const made = store.folders.find((one) => one.name === "Packaging"); + expect(store.docs.find((one) => one.id === doc.id)?.folder).toBe(made?.id); + }); +}); + +describe("what an agent says about a document", () => { + it("marks the row it speaks of, and leaves the document where it was", async () => { + const folder = seedFolder({ name: "Work" }); + seedDoc({ + title: "Handover", + folder: folder.id, + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + const row = screen.getByRole("button", { name: "Handover" }).parentElement as HTMLElement; + expect(within(row).getByTitle(t("docFlagged"))).toBeTruthy(); + const branch = screen.getByRole("button", { name: "Work" }).closest("li") as HTMLElement; + expect(within(branch).getByRole("button", { name: "Handover" })).toBeTruthy(); + }); + + it("says what the agent said, and takes the mark off without touching the document", async () => { + const doc = seedDoc({ + title: "Handover", + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + await userEvent.click(screen.getByRole("button", { name: "Handover" })); + expect(await screen.findByText("The beta channel was retired.")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: t("unflagIt") })); + + await waitFor(() => + expect(store.docs.find((one) => one.id === doc.id)?.flagged).toBeUndefined(), + ); + expect(store.docs.some((one) => one.id === doc.id)).toBe(true); + }); + + it("puts a marked document away when the person says so, and the mark goes quiet", async () => { + const doc = seedDoc({ + title: "Handover", + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + await userEvent.click(screen.getByRole("button", { name: "Handover" })); + await screen.findByTestId("editor"); + await userEvent.click( + within(screen.getByRole("main")).getByRole("button", { name: t("putAway") }), + ); + + await waitFor(() => expect(store.docs.find((one) => one.id === doc.id)?.archived).toBe(true)); + expect(screen.queryByText("The beta channel was retired.")).toBeNull(); + }); }); describe("putting a whole folder away", () => { diff --git a/app/src/ui/Docs.tsx b/app/src/ui/Docs.tsx index 51663dfd..0676eb18 100644 --- a/app/src/ui/Docs.tsx +++ b/app/src/ui/Docs.tsx @@ -12,6 +12,7 @@ import { docLock, docOrder, docRead, + docUnflag, docWrite, type Filed, type Folded, @@ -27,6 +28,7 @@ import { filed, named, pagesOf, under } from "../paging"; import { crowd, ending, MANY, weighed } from "../previews"; import { saidPlainly } from "../refusal"; import { busy, holds, queued } from "../saving"; +import { clientNamed } from "../who"; import Beside, { trailed } from "./Beside"; import Contents from "./Contents"; import Modal from "./Modal"; @@ -87,6 +89,8 @@ interface Props { onTag?: (tag: string) => void; onOwned?: (id: string) => void; onShown?: (file: string | null) => void; + onDrop?: (doc: Filed) => void; + onBack?: (doc: Filed) => void; fresh?: number; } @@ -105,6 +109,8 @@ export default function Docs({ onTag, onOwned, onShown, + onDrop, + onBack, fresh = 0, }: Props) { const [open, setOpen] = useState(null); @@ -700,15 +706,62 @@ export default function Docs({ {t("docShelved")} +
+ )} + {own?.flagged && open && !shelved && ( +
+ + + {clientNamed(own.flagged.via) + ? fill("docFlaggedBy", clientNamed(own.flagged.via) as string) + : t("docFlagged")} + + {stamped(own.flagged.at)} + + {onDrop && !own.locked && ( + + )} + + {own.flagged.said}
)} {bolted && !shelved && open && ( diff --git a/app/src/ui/Sidebar.tsx b/app/src/ui/Sidebar.tsx index d7292492..06d6303b 100644 --- a/app/src/ui/Sidebar.tsx +++ b/app/src/ui/Sidebar.tsx @@ -75,7 +75,7 @@ export default function Sidebar({
-
+
diff --git a/crates/tisty-cli/src/main.rs b/crates/tisty-cli/src/main.rs index 73a6ee01..574f8494 100644 --- a/crates/tisty-cli/src/main.rs +++ b/crates/tisty-cli/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + mod app; mod cmd; mod filter; diff --git a/crates/tisty-cli/src/mcp.rs b/crates/tisty-cli/src/mcp.rs index 700210e4..7d3263c7 100644 --- a/crates/tisty-cli/src/mcp.rs +++ b/crates/tisty-cli/src/mcp.rs @@ -121,6 +121,13 @@ to consult. Writing one creates no task: if something has to happen, propose it. what is written already and the folders it is kept in; you can make a folder and file documents \ into it, but you can never delete or rename one. +A document you find has had its day — a handover for a flow that was retired, notes for a \ +decision long taken — is one you can mark with `flag_doc`, saying what makes it old and how you \ +know. The mark changes nothing and hides nothing: the document stays where it is and reads the \ +same, and the person sees the mark when they open it. Archiving it, deleting it and taking the \ +mark off are all theirs, from the window, the way finishing a task is. `docs` and `read_doc` say \ +which documents carry one, so the same mark is never left twice. + A document can be locked, and a locked one is refused every write: not `write_doc`, not `append_doc`, not `edit_doc`, not `attach`, not hanging a page off it. Its pages are shut with it — `page_doc` neither hangs one off it nor takes one out — and a page is never locked on its own. Filing it in a folder and putting it away still work: what the lock guards is what the document says and what it holds. `docs` and `read_doc` both say so, so you can see it before you try. Only the person can unlock it, from the window — there is no tool for it here, on purpose. A lock is not the archive, though neither one is written in: an archived document is finished, a locked one is guarded. Bring it back with `archive_doc` and it writes again; a lock only the person can lift, from the window. A whole folder can be in the archive too, and then everything under it is — every subfolder, every document, every page — without any of them being marked one by one. What the archive reaches that way is read, exported and packed as always, and written by nobody: no `write_doc`, no `append_doc`, no `edit_doc`, no `attach`, no `page_doc`, no `file_doc` in or out of it, and nothing new goes into that folder — `write_doc` with it as `folder`, `import_doc`, and `folder` naming it as `inside` are all refused, as is changing how it looks. A document in there has no door of its own: `archive_doc` will not hand it back, because only the folder can be brought back, and only by the person from the window. Its own mark is kept untouched while it waits, so a document somebody had archived by hand stays archived when the folder returns. @@ -507,6 +514,7 @@ fn called(paths: &Paths, params: &Value) -> Result { "outline_doc" => outline_doc(paths, &args), "docs" => papers(paths, &args), "archive_doc" => archive_doc(paths, &args), + "flag_doc" => flag_doc(paths, &args), "export_doc" => export_doc(paths, &args), "import_doc" => import_doc(paths, &args), "file_doc" => file_doc(paths, &args), @@ -3416,6 +3424,9 @@ fn papers(paths: &Paths, args: &Value) -> Result { if state.shut(one.id) { kept_of.insert("locked".into(), json!(true)); } + if one.flagged.is_some() { + kept_of.insert("flagged".into(), json!(true)); + } if let Some(card) = card { kept_of.insert("words".into(), json!(card.words)); if !card.outline.is_empty() { @@ -4127,6 +4138,67 @@ fn archive_doc(paths: &Paths, args: &Value) -> Result { )) } +fn flag_doc(paths: &Paths, args: &Value) -> Result { + let Some(which) = text(args, "doc") else { + return Err(Refused::Tool( + "marking a document needs its `doc` name.".into(), + )); + }; + let Some(body) = text(args, "body") else { + return Err(Refused::Tool( + "marking a document needs a `body`: what makes it old and how you know. Without it the person has only your word and nothing to weigh it against." + .into(), + )); + }; + let (state, mut store) = opened(paths)?; + let Some(kept) = state.docs.values().find(|one| one.file == which) else { + return Err(Refused::Tool(format!( + "no document here is called {which:?}. `docs` lists them all." + ))); + }; + if let Some(up) = kept.page_of.and_then(|up| named_doc(&state, up)) { + return Err(Refused::Tool(format!( + "{which} is a page of {up}, and a page is weighed with the document that holds it. Mark {up} instead." + ))); + } + if state.held_away(kept) { + return Err(Refused::Tool(format!( + "{which} is already in the archive, so it is out of the way. Nothing to mark." + ))); + } + if let Some(already) = &kept.flagged { + let who = match already.by == *store.device() && already.via == speaking_through() { + true => "you".to_string(), + false => already + .via + .as_deref() + .map(tisty_core::agent::client_named) + .unwrap_or_else(|| "an assistant".to_string()), + }; + return Err(Refused::Tool(format!( + "{who} already marked {which} on {}, and the person has not looked yet. Marking it again would only say the same thing twice.", + when(already.at) + ))); + } + let id = kept.id; + let me = store.device().clone(); + store + .append(Op::DocFlag { + id, + d: tisty_core::event::Flag::new(body) + .said_by(jiff::Timestamp::now(), me) + .through(speaking_through()), + }) + .map_err(hitch)?; + + Ok(told( + format!( + "Marked {which} as one that has had its day. It is untouched and still reads the same: the person sees the mark when they open it, and archiving it, deleting it or taking the mark off are all theirs." + ), + json!({ "doc": which, "flagged": true }), + )) +} + fn file_doc(paths: &Paths, args: &Value) -> Result { let Some(which) = text(args, "doc") else { return Err(Refused::Tool( @@ -4930,6 +5002,12 @@ fn read_doc(paths: &Paths, args: &Value) -> Result { if state.shut(kept.id) { kept_of.insert("locked".into(), json!(true)); } + if let Some(mark) = &kept.flagged { + kept_of.insert( + "flagged".into(), + json!({ "at": mark.at.to_string(), "said": mark.body }), + ); + } match part_asked(&body, args)? { Part::Outline => { @@ -5711,6 +5789,21 @@ fn tools() -> Value { "required": ["doc"] })) }, + { + "name": "flag_doc", + "title": "Say a document has had its day", + "description": "Mark a document you found is no longer worth keeping — a handover for a flow that was retired, notes for a decision already taken — and say in `body` what makes it old and how you know. The mark changes nothing: the document reads the same, stays where it is, and the person sees the mark when they open it. What happens next is theirs alone: archive it, delete it, or take the mark off. There is no tool here for any of those, on purpose. One mark at a time — a document already marked is refused until the person has looked.", + "inputSchema": shaped(json!({ + "properties": { + "doc": named_doc_field(), + "body": { + "type": "string", + "description": "What makes it old and how you know, in the person's language: what it describes that no longer exists, and what replaced it" + } + }, + "required": ["doc", "body"] + })) + }, { "name": "file_doc", "title": "Put a document in a folder", diff --git a/crates/tisty-cli/tests/mcp.rs b/crates/tisty-cli/tests/mcp.rs index d5ff1ed9..404f4887 100644 --- a/crates/tisty-cli/tests/mcp.rs +++ b/crates/tisty-cli/tests/mcp.rs @@ -315,6 +315,7 @@ fn there_is_no_tool_for_closing_dropping_or_deleting() { "import_doc", "export_doc", "archive_doc", + "flag_doc", "file_doc", "page_doc", "folder", @@ -3477,6 +3478,92 @@ fn what_cannot_survive_the_editor_never_reaches_a_document_that_exists() { ); } +#[test] +fn marking_a_document_leaves_it_where_it_is_and_says_so_in_the_listing() { + let served = Served::new(); + served.cli(&["agent", "--on"]); + let doc = wrote_paper(&served, "# Handover\n\nThe beta channel."); + + let marked = served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "the beta channel was retired in 0.1.0" }), + ); + assert!(marked["result"]["isError"].is_null(), "{marked}"); + assert_eq!(marked["result"]["structuredContent"]["flagged"], true); + + let read = served.call("read_doc", serde_json::json!({ "doc": &doc })); + assert_eq!( + read["result"]["structuredContent"]["flagged"]["said"], + "the beta channel was retired in 0.1.0", + "{read}" + ); + assert!( + read["result"]["structuredContent"]["archived"].is_null(), + "marking it must not put it away: {read}" + ); + + let listed = served.call("docs", serde_json::json!({})); + let here = listed["result"]["structuredContent"]["docs"] + .as_array() + .unwrap() + .iter() + .find(|one| one["doc"] == serde_json::json!(doc)) + .expect("the document left the listing") + .clone(); + assert_eq!(here["flagged"], true, "{listed}"); +} + +#[test] +fn a_document_already_marked_is_not_marked_again_until_the_person_looks() { + let served = Served::new(); + served.cli(&["agent", "--on"]); + let doc = wrote_paper(&served, "# Handover\n\nThe beta channel."); + served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "the beta channel was retired" }), + ); + + let again = served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "it is still of no use" }), + ); + + assert_eq!(again["result"]["isError"], true, "{again}"); + let text = again["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("already marked"), "{again}"); +} + +#[test] +fn marking_needs_the_why_and_is_refused_on_what_the_archive_holds() { + let served = Served::new(); + served.cli(&["agent", "--on"]); + let doc = wrote_paper(&served, "# Handover\n\nThe beta channel."); + + let bare = served.call("flag_doc", serde_json::json!({ "doc": &doc })); + assert_eq!(bare["result"]["isError"], true, "{bare}"); + assert!( + bare["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("`body`"), + "{bare}" + ); + + served.call("archive_doc", serde_json::json!({ "doc": &doc })); + let away = served.call( + "flag_doc", + serde_json::json!({ "doc": &doc, "body": "it has had its day" }), + ); + assert_eq!(away["result"]["isError"], true, "{away}"); + assert!( + away["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("archive"), + "{away}" + ); +} + fn wrote_paper(served: &Served, body: &str) -> String { served.call("write_doc", serde_json::json!({ "body": body }))["result"]["structuredContent"] ["doc"] diff --git a/crates/tisty-core/src/event.rs b/crates/tisty-core/src/event.rs index 88c01100..e4459dd5 100644 --- a/crates/tisty-core/src/event.rs +++ b/crates/tisty-core/src/event.rs @@ -1,9 +1,9 @@ mod op; pub use op::{ - ALIAS_AT_MOST, Body, DeviceKind, DocAdd, Filed, FolderAdd, KNOWN_OPS, ListAdd, LogAdd, LogEdit, - Look, Name, Op, Resolve, Said, Signature, StepAdd, StepRef, StepReorder, StepText, Stitch, - TaskAdd, TaskMove, TaskPatch, + ALIAS_AT_MOST, Body, DeviceKind, DocAdd, Filed, Flag, FolderAdd, KNOWN_OPS, ListAdd, LogAdd, + LogEdit, Look, Name, Op, Resolve, Said, Signature, StepAdd, StepRef, StepReorder, StepText, + Stitch, TaskAdd, TaskMove, TaskPatch, }; use serde::{Deserialize, Serialize}; @@ -121,6 +121,8 @@ impl Event { | Op::DocSigned { id, .. } | Op::DocArchive { id } | Op::DocUnarchive { id } + | Op::DocFlag { id, .. } + | Op::DocUnflag { id } | Op::DocLock { id } | Op::DocUnlock { id } => Some(*id), Op::DeviceJoin { .. } diff --git a/crates/tisty-core/src/event/op.rs b/crates/tisty-core/src/event/op.rs index 9727ddb2..e449db93 100644 --- a/crates/tisty-core/src/event/op.rs +++ b/crates/tisty-core/src/event/op.rs @@ -67,6 +67,8 @@ pub const KNOWN_OPS: &[&str] = &[ "doc.delete", "doc.archive", "doc.unarchive", + "doc.flag", + "doc.unflag", "doc.lock", "doc.unlock", "doc.signed", @@ -169,6 +171,10 @@ pub enum Op { DocArchive { id: DocId }, #[serde(rename = "doc.unarchive")] DocUnarchive { id: DocId }, + #[serde(rename = "doc.flag")] + DocFlag { id: DocId, d: Flag }, + #[serde(rename = "doc.unflag")] + DocUnflag { id: DocId }, #[serde(rename = "doc.lock")] DocLock { id: DocId }, #[serde(rename = "doc.unlock")] @@ -233,6 +239,8 @@ impl Op { matches!( self, Op::DocSaid { .. } + | Op::DocFlag { .. } + | Op::DocUnflag { .. } | Op::Signed { .. } | Op::DocSigned { .. } | Op::DeviceHost { .. } @@ -250,7 +258,11 @@ impl Op { pub fn is_optional(&self) -> bool { matches!( self, - Op::DocSaid { .. } | Op::Signed { .. } | Op::DeviceHost { .. } + Op::DocSaid { .. } + | Op::Signed { .. } + | Op::DeviceHost { .. } + | Op::DocFlag { .. } + | Op::DocUnflag { .. } ) } @@ -295,6 +307,8 @@ impl Op { Op::DocSigned { d, .. } => Op::DocSigned { id, d }, Op::DocDelete { .. } => Op::DocDelete { id }, Op::DocArchive { .. } => Op::DocArchive { id }, + Op::DocFlag { d, .. } => Op::DocFlag { id, d }, + Op::DocUnflag { .. } => Op::DocUnflag { id }, Op::DocUnarchive { .. } => Op::DocUnarchive { id }, Op::DocLock { .. } => Op::DocLock { id }, Op::DocUnlock { .. } => Op::DocUnlock { id }, @@ -333,6 +347,10 @@ impl Op { d.body = one(d.body); Op::TaskLogEdit { id, d } } + Op::DocFlag { id, mut d } => { + d.body = one(d.body); + Op::DocFlag { id, d } + } Op::StepAdd { id, mut d } => { d.text = one(d.text); Op::StepAdd { id, d } @@ -421,6 +439,8 @@ impl Op { | Op::DocDelete { id } | Op::DocArchive { id } | Op::DocUnarchive { id } + | Op::DocFlag { id, .. } + | Op::DocUnflag { id } | Op::DocLock { id } | Op::DocUnlock { id } => Some(*id), Op::DeviceJoin { .. } @@ -594,6 +614,39 @@ impl Resolve { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Flag { + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub via: Option, +} + +impl Flag { + pub fn new(body: impl Into) -> Self { + Self { + body: body.into(), + at: None, + by: None, + via: None, + } + } + + pub fn said_by(mut self, at: jiff::Timestamp, by: DeviceId) -> Self { + self.at = Some(at); + self.by = Some(by); + self + } + + pub fn through(mut self, via: Option) -> Self { + self.via = via; + self + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StepAdd { pub step: StepId, diff --git a/crates/tisty-core/src/model.rs b/crates/tisty-core/src/model.rs index ba3619a6..23c544a3 100644 --- a/crates/tisty-core/src/model.rs +++ b/crates/tisty-core/src/model.rs @@ -9,7 +9,7 @@ mod tag; mod task; pub use date::DateSpec; -pub use folder::{DEEPEST, DocId, FOLDER_NAME_AT_MOST, Folder, FolderId, Kept}; +pub use folder::{DEEPEST, DocId, FOLDER_NAME_AT_MOST, Flagged, Folder, FolderId, Kept}; pub use list::{List, ListId, first_lists, sown, spoken}; pub use repeat::{Cadence, From, Repeat, Unit}; pub use tag::{AGENT_TAG, InvalidTag, Tag}; diff --git a/crates/tisty-core/src/model/folder.rs b/crates/tisty-core/src/model/folder.rs index 59a5e7c1..e429b82b 100644 --- a/crates/tisty-core/src/model/folder.rs +++ b/crates/tisty-core/src/model/folder.rs @@ -40,6 +40,15 @@ impl Folder { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Flagged { + pub at: jiff::Timestamp, + pub by: crate::event::DeviceId, + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub via: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Kept { pub id: DocId, @@ -75,6 +84,10 @@ pub struct Kept { pub locked: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flagged: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_was: Option, } #[cfg(test)] @@ -110,6 +123,8 @@ mod tests { archived: false, locked: false, edited_by: None, + flagged: None, + folder_was: None, }; let json = serde_json::to_string(&kept).unwrap(); diff --git a/crates/tisty-core/src/state.rs b/crates/tisty-core/src/state.rs index 626fdd21..65f9eb7d 100644 --- a/crates/tisty-core/src/state.rs +++ b/crates/tisty-core/src/state.rs @@ -401,6 +401,7 @@ impl State { // would let all of it back out at once, so the mark is written down before the // anchor goes — here, where a delete arriving from another machine lands too. let away = self.folder_away(*id); + let gone = self.folders.get(id).map(|one| one.name.clone()); self.folders.remove(id); self.tombstones.insert(*id); let orphaned: Vec = self @@ -419,6 +420,9 @@ impl State { if doc.folder == Some(*id) { doc.folder = None; doc.archived = doc.archived || away; + if doc.archived { + doc.folder_was = gone.clone(); + } } } } @@ -457,6 +461,8 @@ impl State { .and_then(|one| one.tags.clone()) .unwrap_or_default(), ), + flagged: None, + folder_was: None, }, ); } @@ -520,6 +526,7 @@ impl State { && doc.page_of.is_none() { doc.folder = folder; + doc.folder_was = None; } // Pages live where their document lives, and follow it without being told. if let Some(under) = self.docs.get(id).filter(|one| one.page_of.is_none()) { @@ -568,7 +575,28 @@ impl State { Op::DocArchive { id } => self.shelve(*id, true), Op::DocLock { id } => self.bolt(*id, true), Op::DocUnlock { id } => self.bolt(*id, false), - Op::DocUnarchive { id } => self.shelve(*id, false), + Op::DocUnarchive { id } => { + self.shelve(*id, false); + if let Some(doc) = self.docs.get_mut(id) { + doc.folder_was = None; + } + } + Op::DocFlag { id, d } => { + let said = crate::model::Flagged { + at: d.at.unwrap_or(event.timestamp), + by: d.by.clone().unwrap_or_else(|| event.device.clone()), + body: d.body.clone(), + via: d.via.clone().or_else(|| event.via.clone()), + }; + if let Some(doc) = self.docs.get_mut(id) { + doc.flagged = Some(said); + } + } + Op::DocUnflag { id } => { + if let Some(doc) = self.docs.get_mut(id) { + doc.flagged = None; + } + } Op::DeviceJoin { d, k } => { self.dropped.remove(d); self.devices.insert(d.clone()); @@ -5571,6 +5599,85 @@ mod tests { assert!(!state.docs.contains_key(&march)); } + #[test] + fn what_an_assistant_marked_carries_the_hand_that_said_it() { + let mut state = State::default(); + let one = doc(&mut state, "a3f1-0001", None); + + let mut said = ev( + 2, + "dev_agent", + Op::DocFlag { + id: one, + d: crate::event::Flag::new("the beta channel was retired"), + }, + ); + said.via = Some("claude-code".into()); + state.apply(&said); + + let mark = state.docs[&one].flagged.clone().expect("the mark was lost"); + assert_eq!(mark.by, DeviceId("dev_agent".into())); + assert_eq!(mark.via.as_deref(), Some("claude-code")); + assert_eq!(mark.body, "the beta channel was retired"); + + state.apply(&ev(3, "dev_laptop", Op::DocUnflag { id: one })); + assert!(state.docs[&one].flagged.is_none(), "the person took it off"); + } + + #[test] + fn a_document_in_the_archive_remembers_the_folder_that_was_deleted_under_it() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let one = doc(&mut state, "a3f1-0001", Some(work)); + state.apply(&ev(2, "a", Op::DocArchive { id: one })); + + state.apply(&ev(3, "a", Op::FolderDelete { id: work })); + + assert_eq!(state.docs[&one].folder, None); + assert_eq!(state.docs[&one].folder_was.as_deref(), Some("work")); + } + + #[test] + fn the_trace_of_a_deleted_folder_goes_when_the_document_lands_somewhere() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let one = doc(&mut state, "a3f1-0001", Some(work)); + state.apply(&ev(2, "a", Op::DocArchive { id: one })); + state.apply(&ev(3, "a", Op::FolderDelete { id: work })); + let home = folder(&mut state, "home", None); + + state.apply(&ev(4, "a", Op::DocUnarchive { id: one })); + assert_eq!(state.docs[&one].folder_was, None); + + state.apply(&ev(5, "a", Op::DocArchive { id: one })); + moved( + &mut state, + one, + crate::event::Filed { + folder: Some(Some(home)), + page_of: None, + order: None, + }, + ); + assert_eq!(state.docs[&one].folder, Some(home)); + assert_eq!(state.docs[&one].folder_was, None); + } + + #[test] + fn a_document_out_of_the_archive_when_its_folder_went_keeps_no_trace() { + let mut state = State::default(); + let work = folder(&mut state, "work", None); + let one = doc(&mut state, "a3f1-0001", Some(work)); + + state.apply(&ev(2, "a", Op::FolderDelete { id: work })); + + assert_eq!(state.docs[&one].folder, None); + assert_eq!( + state.docs[&one].folder_was, None, + "it is out in the open, where the tree already says it has no folder" + ); + } + #[test] fn archiving_a_document_puts_its_pages_away_and_brings_them_back() { let mut state = State::default(); diff --git a/crates/tisty-core/src/tagging.rs b/crates/tisty-core/src/tagging.rs index c3016155..1e4c1ec1 100644 --- a/crates/tisty-core/src/tagging.rs +++ b/crates/tisty-core/src/tagging.rs @@ -250,6 +250,8 @@ mod tests { locked: false, tags: vec![Tag::new("legal").unwrap()], edited_by: None, + flagged: None, + folder_was: None, }; let same = crate::event::Said { diff --git a/crates/tisty-core/src/tidy.rs b/crates/tisty-core/src/tidy.rs index 91e26e39..8c0225b2 100644 --- a/crates/tisty-core/src/tidy.rs +++ b/crates/tisty-core/src/tidy.rs @@ -338,6 +338,8 @@ mod tests { archived: false, locked: false, edited_by: None, + flagged: None, + folder_was: None, }, ); }; diff --git a/crates/tisty-core/src/undo.rs b/crates/tisty-core/src/undo.rs index 6cb18fe7..e1be755a 100644 --- a/crates/tisty-core/src/undo.rs +++ b/crates/tisty-core/src/undo.rs @@ -259,7 +259,11 @@ fn undoing(event: &Event, before: &State) -> Option { Op::FolderUnarchive { id } => Some(Op::FolderArchive { id: *id }), Op::FolderDelete { .. } | Op::DocDelete { .. } => None, - Op::DocLock { .. } | Op::DocUnlock { .. } | Op::DocSaid { .. } => None, + Op::DocLock { .. } + | Op::DocUnlock { .. } + | Op::DocSaid { .. } + | Op::DocFlag { .. } + | Op::DocUnflag { .. } => None, Op::TaskDelete { .. } | Op::ListDelete { .. } => None, diff --git a/crates/tisty-core/tests/roundtrip.rs b/crates/tisty-core/tests/roundtrip.rs index b0460e23..7a600d26 100644 --- a/crates/tisty-core/tests/roundtrip.rs +++ b/crates/tisty-core/tests/roundtrip.rs @@ -406,6 +406,8 @@ fn what_a_note_says_a_body_weighs_is_what_the_file_holds() { archived: false, locked: false, tags: Vec::new(), + flagged: None, + folder_was: None, }), "opening it again is news, so it writes another note" ); diff --git a/crates/tisty-core/tests/telling.rs b/crates/tisty-core/tests/telling.rs index 319b2931..d3fd5d76 100644 --- a/crates/tisty-core/tests/telling.rs +++ b/crates/tisty-core/tests/telling.rs @@ -285,6 +285,24 @@ fn where_an_agent_lives_is_a_line_an_older_build_and_undo_both_walk_past() { ); } +#[test] +fn what_an_assistant_says_about_a_document_is_a_line_an_older_build_walks_past() { + let marked = tisty_core::Op::DocFlag { + id: ulid::Ulid::generate(), + d: tisty_core::event::Flag::new("it has had its day"), + }; + let taken_off = tisty_core::Op::DocUnflag { + id: ulid::Ulid::generate(), + }; + + assert!(marked.is_optional(), "an older build would choke on it"); + assert!(taken_off.is_optional(), "an older build would choke on it"); + assert!( + marked.settles() && taken_off.settles(), + "without this, undo stops at a mark that changes nothing" + ); +} + #[test] fn neither_word_about_a_task_being_done_settles_by_itself() { let said = tisty_core::Op::TaskResolve { From 381c5045775f863243c99b9051359e2aa4133cfb Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 14:49:41 -0300 Subject: [PATCH 2/4] fix: the mark goes quiet once the person answers it, and the archive stops inventing folders Archiving a marked document takes the mark off, so nothing is left asking and the archive carries no mark of its own. What the archive keeps of a deleted folder is the whole way down to it, and a folder made again under that same way is the one the document hangs from rather than a second one beside it. Unarchiving asks with a list that fits. The cache holds the document whole, so its schema moves with it: mixed builds were rewriting rows without the mark and reading them back as the truth. --- app/src-tauri/src/lib.rs | 2 +- app/src/App.tsx | 175 ++++++++++++++++---------- app/src/core.ts | 2 +- app/src/index.css | 9 ++ app/src/locales.ts | 10 +- app/src/tests/lifecycle.test.tsx | 53 ++++++-- app/src/ui/Docs.tsx | 4 +- app/src/ui/Sidebar.tsx | 2 +- app/src/ui/Tree.tsx | 60 ++++++--- crates/tisty-cli/src/mcp.rs | 37 ++++-- crates/tisty-core/src/cache.rs | 2 +- crates/tisty-core/src/model/folder.rs | 2 +- crates/tisty-core/src/state.rs | 103 +++++++++++++-- 13 files changed, 333 insertions(+), 128 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e5e2c13b..afea5c5f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3091,7 +3091,7 @@ struct Filed { #[serde(skip_serializing_if = "Option::is_none")] flagged: Option, #[serde(skip_serializing_if = "Option::is_none")] - folder_was: Option, + folder_was: Option>, } #[derive(serde::Serialize)] diff --git a/app/src/App.tsx b/app/src/App.tsx index 6f543f49..22078540 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -484,24 +484,42 @@ export default function App() { setBacking(doc); }; - const backFrom = (doc: Filed): string | null => - doc.folder && papers.folders.some((one) => one.id === doc.folder) - ? trail(doc.folder) - : (doc.folderWas ?? null); + const backHome = (doc: Filed): string | null => { + if (doc.folder && papers.folders.some((one) => one.id === doc.folder)) return doc.folder; + const was = (doc.folderWas ?? []).join(" / "); + const again = was + ? papers.folders.find((one) => !one.away && trail(one.id) === was) + : undefined; + return again?.id ?? null; + }; + + const backFrom = (doc: Filed): string | null => { + const home = backHome(doc); + if (home) return trail(home); + const was = doc.folderWas ?? []; + return was.length ? was.join(" / ") : null; + }; const putBack = () => { const doc = backing; if (!doc) return; setBacking(null); - const home = - doc.folder && papers.folders.some((one) => one.id === doc.folder) ? doc.folder : null; - const made = - backTo === "same" && !home && doc.folderWas - ? folderAdd(doc.folderWas) - : Promise.resolve(backTo === "same" ? home : backTo === "none" ? null : backTo); + const home = backHome(doc); + const lands = (): Promise => + backTo === "none" + ? Promise.resolve(null) + : backTo !== "same" + ? Promise.resolve(backTo) + : home + ? Promise.resolve(home) + : doc.folderWas?.length + ? folderAdd(doc.folderWas[doc.folderWas.length - 1]) + : Promise.resolve(null); docAway(doc.id, false) - .then(() => made) - .then((folder) => (folder === home ? undefined : docFile(doc.id, folder ?? undefined))) + .then(lands) + .then((folder) => + folder === (doc.folder ?? null) ? undefined : docFile(doc.id, folder ?? undefined), + ) .then(papersChanged) .catch((e) => setError(saidPlainly(e))); }; @@ -1300,73 +1318,98 @@ export default function App() { title={fill("backWhere", backing.title || t("untitledDoc"))} onClose={() => setBacking(null)} > -

+

{backFrom(backing) === null ? t("backFromNowhere") - : fill( - backing.folder && papers.folders.some((one) => one.id === backing.folder) - ? "backFrom" - : "backFromGone", - backFrom(backing) as string, - )} + : fill(backHome(backing) ? "backFrom" : "backFromGone", backFrom(backing) as string)}

-
- one.id === backing.folder) - ? "backToSame" - : "backToMade", - backFrom(backing) as string, - ) - } - hint={backFrom(backing) === null ? t("backWasHere") : undefined} - /> - {backFrom(backing) !== null && ( +
{ + e.preventDefault(); + putBack(); + }} + > +
+ {t("backWhere").replace("{name}", "")} - )} - {papers.folders - .filter((one) => !one.away && one.id !== backing.folder) - .map((one) => ( + {backFrom(backing) !== null && ( - ))} -
-
- - -
+ )} + {papers.folders.some((one) => !one.away && one.id !== backHome(backing)) && ( + + )} + +
+ + +
+ )} diff --git a/app/src/core.ts b/app/src/core.ts index 18918bf5..a9089fb3 100644 --- a/app/src/core.ts +++ b/app/src/core.ts @@ -790,7 +790,7 @@ export interface Filed { guest?: string | null; tags?: string[]; flagged?: Marked | null; - folderWas?: string | null; + folderWas?: string[] | null; } export interface Papers { diff --git a/app/src/index.css b/app/src/index.css index 6001f1e7..09f9f34d 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -46,6 +46,7 @@ --tisty-mark-doc: rgb(88 88 100 / 0.12); --tisty-mark-doc-lit: rgb(88 88 100 / 0.2); --tisty-veil: rgb(0 0 0 / 0.28); + --tisty-docs-head: 30px; --tisty-lift: 0 4px 14px rgb(0 0 0 / 0.1), 0 1px 3px rgb(0 0 0 / 0.06); --tisty-lift-tall: 0 16px 44px rgb(0 0 0 / 0.16), 0 2px 8px rgb(0 0 0 / 0.08); } @@ -233,6 +234,14 @@ body { .scroller:has(.carrying) { overscroll-behavior-y: contain; } + +.scroller:has(.carrying) .pinned-head { + pointer-events: none; +} + +.scroller [data-row] { + scroll-margin-top: 56px; +} .scroller:hover { scrollbar-color: var(--tisty-line) transparent; } diff --git a/app/src/locales.ts b/app/src/locales.ts index 7740c7b4..f367e25e 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -551,8 +551,7 @@ const en = { deleteIt: "Delete", docFlagged: "An agent says this has had its day", docFlaggedBy: "{name} says this has had its day", - unflagIt: "Keep it", - flagGone: "Mark taken off", + unflagIt: "Take the mark off", backWhere: "Unarchive «{name}»", backFrom: "It came out of {name}.", backFromGone: "It came out of {name}. That folder is no longer here.", @@ -563,9 +562,7 @@ const en = { backToOther: "Another folder", backWasHere: "where it was", backAtRoot: "outside every folder", - cameFrom: "from {name}", folderTrace: "path", - folderTraceLost: "no longer here", folderTraceWhy: "The folder is still there, outside the archive. This only says where the document came from.", folderTraceGone: "This folder was deleted while the document waited here.", @@ -1877,8 +1874,7 @@ const es: Catalog = { deleteIt: "Eliminar", docFlagged: "Un agente lo da por viejo", docFlaggedBy: "{name} lo da por viejo", - unflagIt: "Conservarlo", - flagGone: "Marca quitada", + unflagIt: "Quitar la marca", backWhere: "Desarchivar «{name}»", backFrom: "Salió de {name}.", backFromGone: "Salió de {name}. Esa carpeta ya no está.", @@ -1889,9 +1885,7 @@ const es: Catalog = { backToOther: "Otra carpeta", backWasHere: "donde estaba", backAtRoot: "fuera de toda carpeta", - cameFrom: "de {name}", folderTrace: "camino", - folderTraceLost: "ya no está", folderTraceWhy: "La carpeta sigue ahí, fuera del archivo. Esto solo dice de dónde salió el documento.", folderTraceGone: "Esta carpeta se borró mientras el documento esperaba aquí.", diff --git a/app/src/tests/lifecycle.test.tsx b/app/src/tests/lifecycle.test.tsx index 4f1d0dd3..af1722e9 100644 --- a/app/src/tests/lifecycle.test.tsx +++ b/app/src/tests/lifecycle.test.tsx @@ -24,7 +24,7 @@ interface FakeDoc { archived: boolean; pageOf?: string; flagged?: { at: string; said: string; via?: string }; - folderWas?: string; + folderWas?: string[]; } const store = vi.hoisted(() => ({ @@ -222,7 +222,10 @@ function backend(cmd: string, args: Record): Promise { } case "doc_away": { const doc = store.docs.find((one) => one.id === args.id); - if (doc) doc.archived = Boolean(args.away); + if (doc) { + doc.archived = Boolean(args.away); + if (doc.archived) doc.flagged = undefined; + } return Promise.resolve(null); } case "doc_unflag": { @@ -385,13 +388,13 @@ async function boot() { unfoldAll(); } -function menuFor(rowLabel: string): HTMLElement { +function menuFor(rowLabel: string | RegExp): HTMLElement { return screen.getByRole("button", { name: rowLabel }).parentElement as HTMLElement; } const SETTLES_LONG_ENOUGH = 900; -async function chooseFor(rowLabel: string, itemLabel: string) { +async function chooseFor(rowLabel: string | RegExp, itemLabel: string) { fireEvent.contextMenu(menuFor(rowLabel), { clientX: 5, clientY: 5 }); await userEvent.click(await screen.findByRole("menuitem", { name: itemLabel })); } @@ -515,13 +518,43 @@ describe("archiving and bringing back a document", () => { expect(screen.getByRole("button", { name: "Work" })).toBeTruthy(); }); + it("hangs the document from the folder that was made again under the same name", async () => { + const folder = seedFolder({ name: "Packaging" }); + const doc = seedDoc({ title: "Report", archived: true, folderWas: ["Packaging"] }); + await boot(); + + await chooseFor("Report", t("bringBack")); + await userEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: t("bringBack") }), + ); + + await waitFor(() => + expect(store.docs.find((one) => one.id === doc.id)?.folder).toBe(folder.id), + ); + expect(store.folders.filter((one) => one.name === "Packaging")).toHaveLength(1); + }); + + it("takes the agent's mark off a document the person puts away", async () => { + const doc = seedDoc({ + title: "Handover", + flagged: { at: "2026-09-23T10:00:00Z", said: "The beta channel was retired." }, + }); + await boot(); + + await chooseFor(/^Handover/, t("putAway")); + + await waitFor(() => expect(store.docs.find((one) => one.id === doc.id)?.archived).toBe(true)); + unfoldAll(); + const shelf = within(screen.getByRole("list", { name: t("archived") })); + expect(shelf.queryByTitle(t("docFlagged"))).toBeNull(); + }); + it("offers to make the folder again when it went while the document waited", async () => { - const doc = seedDoc({ title: "Report", archived: true, folderWas: "Packaging" }); + const doc = seedDoc({ title: "Report", archived: true, folderWas: ["Packaging"] }); await boot(); const shelf = within(screen.getByRole("list", { name: t("archived") })); expect(shelf.getByText("Packaging")).toBeTruthy(); - expect(shelf.getByText(t("folderTraceLost"))).toBeTruthy(); await chooseFor("Report", t("bringBack")); await userEvent.click( @@ -544,10 +577,10 @@ describe("what an agent says about a document", () => { }); await boot(); - const row = screen.getByRole("button", { name: "Handover" }).parentElement as HTMLElement; + const row = screen.getByRole("button", { name: /^Handover/ }).parentElement as HTMLElement; expect(within(row).getByTitle(t("docFlagged"))).toBeTruthy(); const branch = screen.getByRole("button", { name: "Work" }).closest("li") as HTMLElement; - expect(within(branch).getByRole("button", { name: "Handover" })).toBeTruthy(); + expect(within(branch).getByRole("button", { name: /^Handover/ })).toBeTruthy(); }); it("says what the agent said, and takes the mark off without touching the document", async () => { @@ -557,7 +590,7 @@ describe("what an agent says about a document", () => { }); await boot(); - await userEvent.click(screen.getByRole("button", { name: "Handover" })); + await userEvent.click(screen.getByRole("button", { name: /^Handover/ })); expect(await screen.findByText("The beta channel was retired.")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: t("unflagIt") })); @@ -575,7 +608,7 @@ describe("what an agent says about a document", () => { }); await boot(); - await userEvent.click(screen.getByRole("button", { name: "Handover" })); + await userEvent.click(screen.getByRole("button", { name: /^Handover/ })); await screen.findByTestId("editor"); await userEvent.click( within(screen.getByRole("main")).getByRole("button", { name: t("putAway") }), diff --git a/app/src/ui/Docs.tsx b/app/src/ui/Docs.tsx index 0676eb18..4ac07a23 100644 --- a/app/src/ui/Docs.tsx +++ b/app/src/ui/Docs.tsx @@ -707,7 +707,7 @@ export default function Docs({
)} - {own?.flagged && open && !shelved && ( + {own?.flagged && open && !shelved && !own.pageOf && (
-
+