Skip to content

Fix/rivethub web files dirty zustand - #1201

Draft
cesarulo wants to merge 17 commits into
mainfrom
fix/rivethub-web-files-dirty-zustand
Draft

cesarulo wants to merge 17 commits into
mainfrom
fix/rivethub-web-files-dirty-zustand

Conversation

@cesarulo

Copy link
Copy Markdown
Collaborator

Description

Reactoring the web code: modularizing JSX, using existing zustand state manager with reducers for state, making components stateless

Related Issue

Closes #

Checklist

  • Tests added or updated
  • Documentation updated (if applicable)
  • Clean architecture respected (plugins depend on @rivetos/types only)
  • No API keys, tokens, or credentials in the diff
  • Conventional commit message (feat:, fix:, docs:, test:, chore:)
  • npm test passes

rivetphilbot and others added 17 commits October 18, 2026 11:05
…ard state

First sub-key: filesDirty (tracked file, dirty flag, pending discard
decision). One sub-key per guarded surface; agents-form and workflows
join here later. Ephemeral by design, never persisted.
…raction store

editorDirtyRef (a ref mirroring editor dirty state) is gone; the page
reads dirty/fileName from the filesDirty sub-key and the blocker path
prompts/answers through store actions. Pure helpers in files-dirty-guard
keep their signatures (store-agnostic). Wiring pins updated.
…ction store

The editor no longer owns a dirty useState: it reports buffer divergence
through the store (setFilesDirty) and reads the flag back for the
modified badge. The saved-text baseline stays editor-local (mechanics,
not protocol). Workflow-edit-panel keeps its dirty prop contract via
onDirtyChange, so no changes there.
…e type

First modularization commit (plan: llm-steering sessions/files-page-
modularization-plan.md). Pure leaves first: fmtSize/fmtMtime into
pages/files/format.ts with behavioral tests, Notice type into
pages/files/notice.ts. No logic changes.
…ateway

preview-pane.tsx moves out verbatim (stateless, dirty via injected
callback); the in-shell blob-download helper becomes save-via-gateway.ts
(module-level object-URL bookkeeping is download mechanics, not page
state). Page shrinks 864→752.
Row rendering, selection, activation and drag/drop move to
files/file-table.tsx as a stateless component emitting events; the page
keeps guard wiring and all state. The dir-drop upload becomes
uploadInto(dirPath, files) at page level, reusing the same 409-overwrite
flow as toolbar upload. Drops the wiring-pin test file that returned
with main's history — behavior lives in the pure-module and store tests.
…lbar

Both stateless: toolbar takes filter/sort state pairs and action
callbacks; the rename button's entry lookup moves inside the toolbar
(props carry entries). Notice bar rides with the toolbar. Page 604→496.
All mutation ops (upload with 409-overwrite, mkdir, rename, delete with
recursive confirm, move, copy paths/URLs) plus busy/notice state move to
files/use-file-ops.ts. The hook takes injected confirm/prompt functions
and preview-invalidation callbacks so the page keeps owning previewPath
and both confirm-dialog instances. index.tsx is now a composition root:
state, guard wiring, child mount (~344 lines, from 864).
The inline shouldBlockFn returned the raw confirm result, so a cancelled
discard (ok=false) let the navigation proceed. Delegate to the tested
shouldBlockFilesLeave helper, which applies the !ok blocker polarity, and
read the ui-interaction store imperatively so the blocker callback keeps a
stable identity.

Includes [FILES] console probes and window.__router debug hook used to
hand-trace the navigation stall — to be stripped before push.
… state to a view store

Stage 1 of the files-page simplification: filter, sort key, selection set,
and previewPath move out of FilesPage's useState hooks into a dedicated
files-view-store (zustand), same pattern as the dirty-state unification.
FilesPage subscribes to the four values and calls actions imperatively;
toggleSelected replaces the inline Set-copy logic. No behavior change.
… view store

openEntry read the dirty flag from its render closure, but the callback is memoized on deps that never include `dirty`, so the captured value stayed false after typing and guardOpenFile short-circuited: clicking a second file replaced the preview with no discard prompt, while the router blocker — which reads the store at call time — prompted correctly for directories. Read the store at call time here as well. The same stale capture could also go the other way, prompting to discard a file that was already clean.

Also folds the notice bar into the files view store (refactor plan stage 2 lists it as a store member; stage 1 drops the PreviewPane onNotice prop): FilesToolbar and PreviewPane subscribe to the slice instead of taking notice/onNotice props, and the page clears the bar on unmount because the store outlives the page.
…irm dialog per page

The toolbar took `entries` + `selected` only to resolve the single entry Rename acts on. The page now resolves that once and passes `renameTarget?: FileEntry`, and the toolbar reads `selected` straight from the view store for the action-row disabled states. Rename is disabled whenever no target resolves, where before it was enabled on a stale selection and the click silently did nothing.

files.tsx also held two `useConfirmDialog()` instances — the ops confirm/prompt queue and the dirty-guard discard queue — so two modals could be open at once. One instance now serves both and renders a single dialog element.

Sort stays typed as SortKey, but the `as SortKey` cast only moves from the page into the toolbar: `Select` is string-typed and not generic, so the conversion has to happen at the widget boundary somewhere. Typing Select would touch every page that uses it.
Resolving the rename target from the first selected name meant any multi-select produced a target, so Rename enabled with two or more rows checked and would have renamed an arbitrary one of them. Require exactly one selection, as the toolbar did before it took a resolved target instead of the selection itself.
@@ -4,78 +4,29 @@
* upload into the current dir, and drag rows onto folders to move.
*/

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one still looks horrendous but the goal is for it to look like breadcrumbs.tsx, file-table.tsx, or any of the other bits that got componentized away: markup sprinkled with code, preceded by some simple variable declaration and small helper functions. All the state management goes to reducers and all the state is held in zustand, which we already do for a lot of non-UI stuff.

* emits `onOpenEntry` / `onOpenRaw` / `onMoveOntoDir` / `onDropFilesInto`.
*/
export function FileTable(props: {
path: string

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not in love with the number of arguments in some of these. The hope is, once this is modularized, it should be much easier to analyze which of these are truly essential, which can be further componentized away, etc.


export type SortKey = 'name' | 'mtime' | 'size'

interface FilesViewState {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is where all the mess of event propagation that was causing the cross-linked bugs with dirty states in file editor, and each visual component having its own event handlers, some cancelling others, etc went away to: the FileViewState object contains every aspect of what's happening with this visual component, and it gets folded into the Zustand state with a reducer. All decisions about what to ask the user about what, what actions should be allowed, etc are made in one place.

@rivetphilbot rivetphilbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direction looks right: one view store, one ops hook, stateless components, and the dirty guard out of the component tree. Three things before this can go green and come out of draft:

  1. CI typecheck is red. workflow-edit-panel.tsx:325 still passes onDirtyChange to FileEditor, and this PR removed that prop. See the inline note on file-editor.tsx for a fix that also closes a cross-surface leak.
  2. Debug leftover in main.tsx (window.__router). Inline note.
  3. Housekeeping: branch is 5 behind main, please rebase. The title should be a conventional commit, e.g. refactor(rivethub-web): files page view store, ops hook, stateless components. The lint job reports 19 warnings; worth a look to confirm none are new in the touched files.

Happy to re-review once it's green.

// Dirty is protocol state — it lives in the ui-interaction store keyed by
// the tracked file; the editor only reports divergence and reads it back
// for the modified badge.
const dirty = useUiInteraction((s) => s.filesDirty.dirty)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with reading filesDirty here.

  • FileEditor is also mounted by workflow-edit-panel.tsx, which still passes onDirtyChange (that's the CI typecheck failure).
  • Every FileEditor instance now reads and writes the single filesDirty slot, so the workflow panel's editor would flip the Files page dirty flag and show the Files page badge. That is the same cross-surface coupling this PR is removing, just moved into the store. The store header already says one sub-key per guarded surface, so the editor shouldn't pick a surface itself.

Suggest keeping the editor stateless about which surface it belongs to: keep onDirtyChange?: (dirty: boolean) => void on FileEditorProps (and a local dirty for the badge, or a dirty?: boolean prop), and have the Files page / PreviewPane wire onDirtyChange={setFilesDirty} from useUiInteraction. The workflow panel keeps working unchanged, the Files page still owns its dirty state in the store, and the editor has no store dependency.


const router = createRouter({ routeTree })
// Debug: expose the router for hand-tracing navigations in devtools.
;(window as unknown as { __router?: unknown }).__router = router

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop this before merge; it ships a global in the production bundle. If it's useful while tracing, gate it behind import.meta.env.DEV in a local branch rather than committing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants