diff --git a/.gitignore b/.gitignore
index fabc59673b602310da3e23d20703e29f6bb3976d..1f13b77fe16a7fd6953beb5d4e13fca2a358fb3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@
 *.outline
 *.spine
 *.mat
+*.label
diff --git a/Manifest-v1.11.toml b/Manifest-v1.11.toml
index f2d4c37b4ed7ab406a7b3b4bf344e8d1dbbfd2ed..53ceb33cf5341554f9098597cda4465d227dcd00 100644
--- a/Manifest-v1.11.toml
+++ b/Manifest-v1.11.toml
@@ -1,8 +1,8 @@
 # This file is machine-generated - editing it directly is not advised
 
-julia_version = "1.11.1"
+julia_version = "1.11.3"
 manifest_format = "2.0"
-project_hash = "fccf84810b56f9ba8947369bbaf414d318bc47f2"
+project_hash = "423cfe5e2b370a6df6f85f87fd1406fd07136176"
 
 [[deps.AbstractFFTs]]
 deps = ["LinearAlgebra"]
@@ -985,11 +985,11 @@ version = "1.2.0"
 
 [[deps.NyxWidgets]]
 deps = ["Bonito", "Colors", "Format", "LazyArtifacts", "Observables"]
-git-tree-sha1 = "936f80aa61413c47da00f96abbc0186078698bca"
+git-tree-sha1 = "e18ab14817871c54419e4cef12f9fc4dc589f6fe"
 repo-rev = "main"
 repo-url = "https://gitlab.com/dbc-nyx/NyxWidgets.jl"
 uuid = "c288fd06-43d3-4b04-8307-797133353e2e"
-version = "0.1.1"
+version = "0.2.0"
 
 [[deps.Observables]]
 git-tree-sha1 = "7438a59546cf62428fc9d1bc94729146d37a7225"
diff --git a/Manifest.toml b/Manifest.toml
index d02ed0ce6003ec651f18217bf255430a7306468b..cf255d7a031755ab63cb7eef5552258efa33f301 100644
--- a/Manifest.toml
+++ b/Manifest.toml
@@ -1,8 +1,8 @@
 # This file is machine-generated - editing it directly is not advised
 
-julia_version = "1.10.6"
+julia_version = "1.10.8"
 manifest_format = "2.0"
-project_hash = "0fb14e688a33d3d8ba0bbce1542d1ada113967c7"
+project_hash = "029a32b6e21d0f2c52e6dccd0a009e9681bdff18"
 
 [[deps.AbstractFFTs]]
 deps = ["LinearAlgebra"]
@@ -971,11 +971,11 @@ version = "1.2.0"
 
 [[deps.NyxWidgets]]
 deps = ["Bonito", "Colors", "Format", "LazyArtifacts", "Observables"]
-git-tree-sha1 = "936f80aa61413c47da00f96abbc0186078698bca"
+git-tree-sha1 = "e18ab14817871c54419e4cef12f9fc4dc589f6fe"
 repo-rev = "main"
 repo-url = "https://gitlab.com/dbc-nyx/NyxWidgets.jl"
 uuid = "c288fd06-43d3-4b04-8307-797133353e2e"
-version = "0.1.1"
+version = "0.2.0"
 
 [[deps.Observables]]
 git-tree-sha1 = "7438a59546cf62428fc9d1bc94729146d37a7225"
diff --git a/Project.toml b/Project.toml
index 64da699db05a973147106bdfa26c67f49c02551a..60848ef53384d300254e240b3260af26052804df 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,7 +1,7 @@
 name = "LarvaTagger"
 uuid = "8b3b36f1-dfed-446e-8561-ea19fe966a4d"
 authors = ["François Laurent", "Institut Pasteur"]
-version = "0.19.0"
+version = "0.19.1"
 
 [deps]
 Bonito = "824d6782-a2ef-11e9-3a09-e5662e0c26f8"
@@ -27,7 +27,7 @@ WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008"
 
 [compat]
 Bonito = "< 4.0.0"
-NyxWidgets = "0.1.1"
+NyxWidgets = ">= 0.2.0"
 ObservationPolicies = "0.2.4"
 PlanarLarvae = ">= 0.11.2"
 TidyObservables = "0.1.1"
diff --git a/recipes/Dockerfile b/recipes/Dockerfile
index 4489a17ed460154b4c004ea8f3fc2a33409d1a45..11d0e1c7eed52e319119861f568fd68bbf6351e1 100644
--- a/recipes/Dockerfile
+++ b/recipes/Dockerfile
@@ -1,4 +1,4 @@
-FROM julia:1.10.6-bullseye AS base
+FROM julia:1.10.8-bullseye AS base
 
 ARG PROJECT_DIR=/app
 ARG BRANCH=main
diff --git a/scripts/larvatagger b/scripts/larvatagger
index 5a0a65aed7cc2f537d6e5701784cba00852c3d00..c6007ad05c627fae96f6706abe230f56d1bcdaf0 100755
--- a/scripts/larvatagger
+++ b/scripts/larvatagger
@@ -52,7 +52,7 @@ import|merge|--version|-V)
 LarvaTagger
 
 Usage:
-  larvatagger open [<file-path>] [--backends=<path>] [--port=<number>] [--quiet] [--viewer] [--browser] [--view-factor=<real>] [--manual-label=<label>]
+  larvatagger open [<file-path>] [--backends=<path>] [--port=<number>] [--server-url=<url>] [--quiet] [--viewer] [--browser] [--view-factor=<real>] [--manual-label=<label>]
   larvatagger import <input-path> [<output-file>] [--id=<id>] [--framerate=<fps>] [--pixelsize=<μm>] [--overrides=<comma-separated-list>] [--default-label=<label>] [--manual-label=<label>] [--decode] [--copy-labels]
   larvatagger train <backend-path> <data-path> <model-instance> [--pretrained-model=<instance>] [--labels=<comma-separated-list>] [--sample-size=<N>] [--balancing-strategy=<strategy>] [--class-weights=<csv>] [--manual-label=<label>] [--layers=<N>] [--iterations=<N>] [--seed=<seed>] [--debug]
   larvatagger train <backend-path> <data-path> <model-instance> --fine-tune=<instance> [--balancing-strategy=<strategy>] [--manual-label=<label>] [--iterations=<N>] [--seed=<seed>] [--debug]
@@ -71,6 +71,7 @@ Options:
   --pixelsize=<μm>      Camera pixel size, in micrometers.
   --backends=<path>     Path to backend repository.
   --port=<number>       Port number the server listens to.
+  --server-url=<url>    Server address, for remote access.
   --viewer              Disable editing capabilities.
   --browser             Automatically open a browser tab at the served location.
   --view-factor=<real>  Scaling factor for the larva views; default is 2 on macOS, 1 elsewhere.
@@ -104,6 +105,8 @@ Commands:
     The optional positional argument <file-path> can also be the data root directory.
     Backends defined in LarvaTagger project root directory are automatically found. Other
     backend locations can be specified with the --backends argument.
+    By default, if --server-url is specified, the port is appended to the ip address or
+    domain name. To prevent this behavior, include the desired port number in the url.
 
   import    Generate a label file and store metadata.
 
diff --git a/src/cli_open.jl b/src/cli_open.jl
index 56ab8b563ed874cd35e429b3623872a417399aea..a1d46b678e4e4faf1b2790a1202297a6b354ca0e 100644
--- a/src/cli_open.jl
+++ b/src/cli_open.jl
@@ -9,7 +9,7 @@ export main
 usage = """LarvaTagger.jl - launch the server-based GUI.
 
 Usage:
-  larvatagger-gui.jl [<file-path>] [--backends=<path>] [--port=<number>] [--quiet] [--viewer] [--browser] [--view-factor=<real>] [--manual-label=<label>]
+  larvatagger-gui.jl [<file-path>] [--backends=<path>] [--port=<number>] [--server-url=<url>] [--quiet] [--viewer] [--browser] [--view-factor=<real>] [--manual-label=<label>]
   larvatagger-gui.jl -h | --help
 
 Options:
@@ -17,6 +17,7 @@ Options:
   -q --quiet              Do not show instructions.
   --backends=<path>       Path to backend repository.
   --port=<number>         Port number the server listens to.
+  --server-url=<url>      Server address, for remote access.
   --viewer                Disable editing capabilities.
   --browser               Automatically open a browser tab at the served location.
   --view-factor=<real>    Scaling factor for the larva views; default is 2 on macOS, 1 elsewhere.
@@ -25,6 +26,9 @@ Options:
 The optional positional argument <file-path> can also be the data root directory.
 Backends defined in LarvaTagger project root directory are automatically found. Other
 backend locations can be specified with the --backends argument.
+
+By default, if --server-url is specified, the port is appended to the ip address or domain
+name. To prevent this behavior, include the desired port number in the url.
 """
 
 function main(args=ARGS; exit_on_error=false)
@@ -82,12 +86,30 @@ function main(args=ARGS; exit_on_error=false)
     #
     port = parsed_args["--port"]
     port = isnothing(port) ? 9284 : parse(Int, port)
-    server = Server(app, "0.0.0.0", port)
+    proxy_url = parsed_args["--server-url"]
+    if isnothing(proxy_url)
+        proxy_url = ""
+    elseif !startswith(proxy_url, "http")
+        proxy_url = "http://$(proxy_url)"
+    end
+    server = Server(app, "0.0.0.0", port; proxy_url=proxy_url)
+    port = server.port
+    if !isempty(proxy_url)
+        protocol, remainder = split(proxy_url, "://")
+        if !(':' in remainder)
+            server.proxy_url = proxy_url = if '/' in remainder
+                server_name, path = split(remainder, '/'; limit=2)
+                "$(protocol)://$(server_name):$(port)/$(path)"
+            else
+                "$(protocol)://$(remainder):$(port)"
+            end
+        end
+    end
     if parsed_args["--browser"]
         Bonito.HTTPServer.openurl("http://127.0.0.1:$(port)")
     end
     if verbose
-        @info "The server is ready at http://127.0.0.1:$(port)"
+        @info "The server is ready at $(Bonito.HTTPServer.online_url(server, ""))"
     end
     if verbose
         @info "Press Ctrl+D to stop the server (or Ctrl+C if in PowerShell)"
diff --git a/src/controllers.jl b/src/controllers.jl
index 0482579159b7448a9deb1759c8bec5c9ae96a2ea..ee7fae7f3158be1114ed09e3e38b01f494871fbc 100644
--- a/src/controllers.jl
+++ b/src/controllers.jl
@@ -100,36 +100,6 @@ function createtag!(controller, tag::ObservableTag)
     return true
 end
 
-function renametag!(controller, tag::ObservableTag, newtag::Union{Nothing, UserTag}=nothing)
-    tagname = tag.name[]
-    newtag = @something newtag tagname
-    if isempty(newtag)
-        @warn "empty tag name"
-        return false
-    elseif newtag != tagname
-        if exists(tag, controller)
-            renamefailed!(controller, tag, newtag)
-            return false
-        else
-            @info "Tag \"$(tagname)\" renamed \"$(newtag)\""
-            transaction = tagname => newtag
-            push!(transactions(controller), RenameTag(transaction))
-            tagevents(controller).renamed[] = transaction
-        end
-    end
-    return true
-end
-
-function renamefailed!(controller,
-        current::ObservableTag,
-        failure::UserTag,
-    )
-    tagname = current.name[]
-    @info "Renaming tag \"$(tagname)\" failed"
-    tagevents(controller).renamefailed[] = tagname => failure
-    notify(gettags(controller))
-end
-
 function activatetag!(controller, tag::ObservableTag)
     tag.active[] && return false
     tagname = tag.name[]
@@ -161,6 +131,8 @@ function exists(tagname::String, tagsource, lut)
     return false
 end
 
+exists(tagsource::ObservableTag, lut) = exists(tagsource.name[], lut)
+
 function assignmentfailed!(controller, reason)
     taggingevents(controller).assignmentfailed[] = reason
 end
@@ -224,6 +196,15 @@ getplayer(hub::ControllerHub) = haskey(hub, :player) ? hub[:player] : getplayer(
 gettags(c) = gettags(gethub(c)[:larva])
 gettags(c::LarvaController) = c.tag_lut
 
+getmanualtag(c) = getmanualtag(gethub(c))
+function getmanualtag(c::ControllerHub)
+    tag = get(c, :manualtag, nothing)
+    if tag isa Symbol
+        tag = string(tag)
+    end
+    return tag
+end
+
 # events
 
 function hoverlarva!(controller::LarvaController, larva_id::LarvaID)
@@ -375,15 +356,22 @@ function setevents!(controller, tagvalidator::ValidatorBundle{ObservableTag})
             deactivatetag!(controller, tag)
         end
     end
+    on(modelfailed(tagvalidator, :name)) do (_, failure)
+        @error "Tag name update failure handled on the model side" tag.name[] failure
+        # this fails to trigger evaljs
+        tagevents(controller).renamefailed[] = (tag.name[] => failure)
+    end
     on(viewfailed(tagvalidator, :name)) do (_, failure)
         tagevents(controller).renamefailed[] = (tag.name[] => failure)
     end
-    on(tagvalidator, :name) do newname, curname
-       #renametag!(controller, tag)
-       transaction = curname => newname
-       push!(transactions(controller), RenameTag(transaction))
-       tagevents(controller).renamed[] = transaction
-       return Validate
+    previousname = Ref(tag.name[])
+    on(tag.name) do newname
+        prevname = previousname[]
+        @assert newname != prevname
+        @info "Tag \"$(prevname)\" renamed \"$(newname)\""
+        transaction = prevname => newname
+        push!(transactions(controller), RenameTag(transaction))
+        previousname[] = newname
     end
     onany(tag.active, tag.name, tag.color) do _, _, _
         notify(gettags(controller))
diff --git a/src/editor.jl b/src/editor.jl
index abf8b403d3f060476ffa83f071b7b1e165e7cbf9..ad40fb617e22a23c656a06f82595d154bbd3fd28 100644
--- a/src/editor.jl
+++ b/src/editor.jl
@@ -35,11 +35,18 @@ function larvaeditor(path=nothing;
         backend_directory::AbstractString=projectdir,
         manualtag::Union{Nothing, String, Symbol}="edited",
         title="LarvaTagger",
+        root_directory=nothing,
+        enable_uploads=false,
+        enable_downloads=false,
+        prepare_download=nothing,
+        enable_new_directories=false,
+        enable_delete=false,
         kwargs...)
 
     # to (re-)load a file, the app is reloaded with the filepath as sole information
     # from previous session
-    input = Ref{Union{Nothing, String}}(path)
+    T = Ref{Union{Nothing, String}}
+    input = path isa T ? path : T(path)
 
     App(title=title) do session::Session
 
@@ -47,6 +54,12 @@ function larvaeditor(path=nothing;
         # used for method dispatch (here `Session` is taken to represent Bonito)
         controller[:frontend] = Session
 
+        controller[:manualtag] = manualtag
+
+        if !isnothing(root_directory)
+            controller[:workingdirectory] = workingdir(controller, root_directory)
+        end
+
         tryopenfile(controller, input)
 
         editor = EditorView(larvaviewer(controller;
@@ -54,9 +67,13 @@ function larvaeditor(path=nothing;
                                         multipletags=allow_multiple_tags,
                                         kwargs...),
                             larvafilter(controller),
-                            tagfilter(controller; manualtag=manualtag),
+                            tagfilter(controller),
                             metadataeditor(controller),
-                            filemenu(controller),
+                            filemenu(controller; upload_button=enable_uploads,
+                                     download_button=enable_downloads,
+                                     prepare_download=prepare_download,
+                                     create_directory_button=enable_new_directories,
+                                     delete_button=enable_delete),
                             backendmenu(controller, backend_directory),
                             loadanimation(controller),
                             twooptiondialog(controller))
diff --git a/src/files.jl b/src/files.jl
index 397c78160d2df38634a5202d36675cc61b279c54..100d81d8ff00940cd436e091bf046ce385a688b3 100644
--- a/src/files.jl
+++ b/src/files.jl
@@ -7,7 +7,9 @@ struct WorkingDirectory
     content::Observable{Vector{String}}
 end
 
-WorkingDirectory(controller, root::String, path::String="") = WorkingDirectory(controller, root, Validator(path), Observable(String[]))
+function WorkingDirectory(controller, root::String, path::String="")
+    WorkingDirectory(controller, root, Validator(path), Observable(String[]))
+end
 
 function workingdir(controller, root::String, path::String=""; secure::Bool=true)
     if secure
@@ -107,7 +109,7 @@ function tryopenfile(controller, path; reload::Bool=false)
         @info "Cannot load file" file=path
         return
     elseif haskey(hub, :input)
-        hub[:input][] = isabspath(path) ? relpath(path) : path
+        hub[:input][] = path
     end
     # tag_lut
     fallback_color = theme[:LarvaPlot][:fallback_color]
@@ -185,8 +187,9 @@ function tryopenfile(controller, path; reload::Bool=false)
     end
     secondarytags = records[:secondarytags]
     if !isnothing(secondarytags)
+        manualtag = getmanualtag(hub)
         for tag in secondarytags
-            original = TagModel(tag, true)
+            original = TagModel(tag, true; frozen=tag == manualtag)
             push!(tag_lut, ObservableTag(original; color=fallback_color, active=true))
         end
     end
@@ -274,7 +277,7 @@ function savetofile(controller, file; datafile=nothing, merge=false)
     @assert length(dataset) == 1
     run = first(values(dataset))
     lut = gettags(controller)[]
-    manualtag = gethub(controller)[:tag].manualtag
+    manualtag = getmanualtag(controller)
     has_explicit_editions = false
     if !explicit_editions_needed(controller, manualtag)
         manualtag = nothing
@@ -356,6 +359,7 @@ function savetofile(controller, file; datafile=nothing, merge=false)
         end
         Datasets.to_json_file(filepath, dataset)
         Taggers.check_permissions(filepath)
+        notify(FileBrowsers.workingdir(gethub(controller)[:browser]))
     end
 end
 
diff --git a/src/larvatagger.css b/src/larvatagger.css
index 0ead1806f7f3b3194136418789a3d16ca34ad755..013b8a4acf974db08bbde37249d883491a754cab 100644
--- a/src/larvatagger.css
+++ b/src/larvatagger.css
@@ -40,7 +40,7 @@ canvas {
 .cp-tab .cp-tab-switch:checked ~ .control-panel {
 	display: block;
 }
-.cp-tab .cp-tab-switch:checked ~ .control-panel div {
+.cp-tab .cp-tab-switch:checked ~ .control-panel > div {
 	background: var(--theme-main-color);
 }
 .control-panel {
@@ -63,6 +63,10 @@ canvas {
   background-color: var(--nyx-icon-fill-color);
 }
 
+.nyx-filebrowser-entrycontrols {
+  margin-left: -2.25rem;
+}
+
 div.scrollable {
 	position: relative;
 	margin-right: 0;
diff --git a/src/models.jl b/src/models.jl
index 9910a28cadded6c41161031c487c93d567b6fc9e..6000cc4d287ffed3e46364258149316ce38c9d28 100644
--- a/src/models.jl
+++ b/src/models.jl
@@ -19,10 +19,22 @@ struct TagModel <: AbstractTag
     color::OptionalColor
     active::Union{Nothing, Bool}
     secondary::Bool
+    frozen::Bool
 end
 
-TagModel(name::Name, secondary::Bool=false) = TagModel(name, nothing, nothing, secondary)
-TagModel(name::Name, color::OptionalColor, active::Union{Nothing, Bool}) = TagModel(name, color, active, false)
+function TagModel(name::Name, secondary::Bool=false; frozen::Bool=false)
+    TagModel(name, nothing, nothing, secondary, frozen)
+end
+function TagModel(name::Name,
+        color::OptionalColor,
+        active::Union{Nothing, Bool},
+        secondary::Bool=false,
+    )
+    TagModel(name, color, active, secondary, false)
+end
+
+isfrozen(tag::TagModel) = tag.frozen
+isfrozen(::Nothing) = false
 
 struct ObservableTag <: AbstractTag
     name::AbstractObservable{String}
@@ -54,6 +66,8 @@ end
 
 ObservableTag() = ObservableTag("", "#000000")
 
+isfrozen(t::ObservableTag) = isfrozen(t.original)
+
 isnative(t::ObservableTag) = !isnothing(t.original)
 
 const TagLUT = Vector{ObservableTag}
@@ -204,8 +218,9 @@ function TidyObservables.newcontroller(tag::ObservableTag, taglut::AbstractObser
     on(ctrl, :name) do name, current
         if isempty(name)
             Invalidate(; revert=!isempty(current))
+        elseif isfrozen(tag)
+            Invalidate(; revert=name != current)
         elseif exists(name, tag, taglut)
-            @info (name, current, lookup(taglut[], name))
             Invalidate()
         else
             Validate(name)
diff --git a/src/plots.jl b/src/plots.jl
index 4a76a07e37abd5b8b02ec19cc83924fb95b49504..2ab059ba16498000d7d2f2a6bdc3a2122754302e 100644
--- a/src/plots.jl
+++ b/src/plots.jl
@@ -450,9 +450,9 @@ function setkeyboardevents!(scene::Scene, controller)
     on(events(scene).keyboardbutton) do event
         if event.action == Keyboard.press
             if event.key in (Keyboard.left, Keyboard.down)
-                stepbackward!(player)
+                Players.stepbackward(player)
             elseif event.key in (Keyboard.right, Keyboard.up)
-                stepforward!(player)
+                Players.stepforward(player)
             end
         end
     end
@@ -562,7 +562,8 @@ function DecoratedLarvae(larvae::Vector{DecoratedLarva})
         @assert hovering_active[]
         i = current_larva[]
         if i == j
-            @warn "moving too fast? - please file an issue at https://gitlab.pasteur.fr/nyx/larvatagger.jl/-/issues"
+            @warn "Moving too fast?"
+            return
         end
         if 0 < i
             i_decorated = larvae[i].decorated
diff --git a/src/wgl.jl b/src/wgl.jl
index 9bdb2a73ed716cd420fa39d7bcd5b54b57b76f2d..fb4640d76c361210a95344674d35ec4f27d736bf 100644
--- a/src/wgl.jl
+++ b/src/wgl.jl
@@ -1,5 +1,6 @@
 getsession(c) = gethub(c)[:session]
 setsession!(c, session) = (gethub(c)[:session] = session)
+using Makie: label_info
 
 const r = Bonito.jsrender
 
@@ -431,6 +432,7 @@ mutable struct TagController
     hub::ControllerHub
     view
     fallback_color::String
+    # deprecation: manualtag is now stored in `hub` as well with key :manualtag
     manualtag
 end
 
@@ -451,21 +453,28 @@ function TagController(controller::ControllerHub;
                   manualtag)
 end
 
-getmanualtag(c) = gethub(c)[:tag].manualtag
+#getmanualtag(c::TagController) = c.manualtag
 
 newtagname(::TagController) = NoTag
 
+isfrozen(c::TagController, tag) = isfrozen(lookup(gettags(c)[], tag, true))
+
 struct IndividualTagView
     controller::TagController
     tag::ObservableTag # view
+    frozen::Bool
     dom_id
 end
 
-function IndividualTagView(controller::TagController, tag::ObservableTag)
-    IndividualTagView(controller, tag, dom_id())
+function IndividualTagView(controller::TagController, tag::ObservableTag, frozen::Bool)
+    IndividualTagView(controller, tag, frozen, dom_id())
+end
+
+function tagview(controller::TagController, tag::ObservableTag)
+    IndividualTagView(controller, tag, isfrozen(tag))
 end
 
-tagview(controller, tag) = IndividualTagView(controller, tag)
+isfrozen(tag::IndividualTagView) = tag.frozen
 
 function lowerdom(ti::IndividualTagView)
     tag = ti.tag
@@ -497,8 +506,9 @@ function Bonito.jsrender(session::Session, ti::IndividualTagView)
     tag = ti.tag
     selector = dom_selector(ti) * " .tag-name"
     events = tagevents(ti.controller)
-    on(events.renamefailed) do (name, failure)
-        name == tag.name[] || return
+    on(session, events.renamefailed) do (name, failure)
+        # tag.name[] == failure || return # if event triggered by model failure
+        tag.name[] == name || return # if event triggered by view failure
         @info "Reverting to \"$(name)\" after failure: \"$(failure)\""
         evaljs(session, js"document.querySelector($selector).value = $name;")
     end
@@ -542,8 +552,10 @@ function tagfilter(controller; manualtag=nothing)
 end
 
 function lowerdom(tf::TagFilter)
+    manualtag = getmanualtag(tf.controller)
     tags = newview!(gethub(tf.controller)[:taghooks])
     for tag in reverse(tags)
+        tag.name[] == manualtag && continue
         push!(tf.views, tagview(tf.controller, tag))
     end
     return DOM.div(scrollable(tf.views...),
@@ -609,49 +621,47 @@ function TagSelector(controller::TagController,
         multiple::Union{Nothing, Bool}=nothing,
     )
     tag_lut = gettags(controller)
-    getnames(tags) = [tag.name[] for tag in tags if tag.active[]]
-    tagnames = Observable(getnames(tag_lut[]))
-    on(tag_lut) do tags
-        tagnames[] = getnames(tags)
+    manualtag = getmanualtag(controller)
+    tagnames = map(tag_lut) do tags
+        [tag.name[] for tag in tags if tag.active[] && tag.name[] != manualtag]
     end
     registered_observables = Observable{Bool}[]
-    selectedtags = Observable(Tuple{String, Observable{Bool}}[])
+    selectedtags = map(tagnames; ignore_equal_values=true) do names
+        for i in length(registered_observables)+1:length(names)
+            newobs = Observable(false)
+            on(newobs) do b
+                @debug "Tag $(i) $(b ? "" : "un")activated"
+            end
+            push!(registered_observables, newobs)
+        end
+        collect(zip(names, registered_observables[1:length(names)]))
+    end
+    refresh_view = map(selectedtags) do _
+        true
+    end
     multitag = filterevents(controller).allow_multiple_tags
     if !isnothing(multiple)
         multitag[] = multiple
     end
+    fromjs = nothing
     if selectable
         fromjs = Observable("")
         on(fromjs) do actuatedtag
             for (tag, selected) in selectedtags[]
                 if tag == actuatedtag
-                    toggletag!(controller, tag, selected, selectedtags, selectable)
-                    flag_active_larva_as_edited(controller)
+                    if isfrozen(controller, tag)
+                        @warn "Actuated tag is frozen" actuatedtag
+                        selected[] = !selected[] # this observable reflects the state of the
+                        # UI; the option element is in an improper state, which will be
+                        # undone on the next update of the selector
+                    else
+                        toggletag!(controller, tag, selected, selectedtags, selectable)
+                        flag_active_larva_as_edited(controller)
+                    end
                     break
                 end
             end
         end
-    else
-        fromjs = nothing
-    end
-    ctrl = TagSelector(controller,
-                       tagnames,
-                       selectedtags,
-                       selectable,
-                       isnothing(multiple),
-                       fromjs,
-                       dom_id())
-    refresh_view = Observable(false)
-    on(tagnames) do names
-        for i in length(registered_observables)+1:length(names)
-            newobs = Observable(false)
-            on(newobs) do b
-                @debug "Tag $(i) $(b ? "" : "un")activated"
-            end
-            push!(registered_observables, newobs)
-        end
-        selectedtags[] = collect(zip(names, registered_observables[1:length(names)]))
-        notify(refresh_view)
     end
     activelarva = getactivelarva(controller)
     onany(gettimestep(controller),
@@ -685,12 +695,22 @@ function TagSelector(controller::TagController,
         #notify(refresh_view) # applies only to the tag selector
         notify(gettimestep(controller)) # full refresh
     end
-    return ctrl
+    return TagSelector(controller,
+                       tagnames,
+                       selectedtags,
+                       selectable,
+                       isnothing(multiple),
+                       fromjs,
+                       dom_id())
 end
 
 function refresh_selected_tags(controller, id, timestep, taglut, selectedtags, selectable)
     larva = getlarva(controller, id)
     tags = getusertags(larva, timestep; lut=taglut)
+    refresh_selected_tags(controller, tags, selectedtags, selectable)
+end
+
+function refresh_selected_tags(controller, tags::UserTags, selectedtags, selectable)
     tagnames = [tag.name[] for tag in tags]
     jscode = String[]
     for (name, selected) in Observables.to_value(selectedtags)
@@ -741,32 +761,31 @@ function refresh_selected_tag(controller,
 end
 
 function toggletag!(controller, tagname, selected, selectedtags, selectable)
-    selected[] = !selected[]
     multitag = filterevents(controller).allow_multiple_tags[]
-    multitag || selected[] || @debug "Deselecting a tag in a single tag setting"
-    #
     larva_id = getactivelarva(controller)[]
     larva = getlarva(controller, larva_id)
     timestep = gettimestep(controller)[]
-    taglut = gettags(controller)[]
+    taglut = gettags(controller)[] # should not be mutated here, therefore we use the model
+    # instead of the view; otherwise use the view gethub(controller)[:taghooks]
     tags = getusertags!(larva, timestep; lut=taglut)
     tag = lookup(taglut, tagname)
     isnothing(tag) && throw(KeyError(tagname))
-    #
-    if selected[]
+    # toggle
+    selected′= !selected[]
+    transaction = if selected′
         if multitag || isempty(tags)
-            transaction = AddTag(larva_id, timestep, tagname)
             settag!(tags, tag)
+            refresh_selected_tags(controller, tags, selectedtags, selectable)
+            AddTag(larva_id, timestep, tagname)
         else
-            transaction = OverrideTags(larva_id, timestep, tagname, collect(tags))
             settag!(tags, tag; single=true)
-            #
             refresh_selected_tag(controller, tag, selectedtags, selectable)
+            OverrideTags(larva_id, timestep, tagname, collect(tags))
         end
     else
         @assert !isempty(tags)
-        transaction = RemoveTag(larva_id, timestep, tagname)
         deletetag!(tags, tag)
+        RemoveTag(larva_id, timestep, tagname)
     end
     push!(transactions(controller), transaction)
     notify(larva.usertags)
@@ -808,11 +827,7 @@ function Bonito.jsrender(session::Session, ts::TagSelector)
     if ts.selectable
         evaljs(session, js"LarvaTagger.setTagSelector($(ts.fromjs))")
     end
-    selectedtags = ts.selected
-    prevtags = Ref(empty(selectedtags[]))
-    on(session, selectedtags) do selected
-        selected == prevtags[] && return
-        prevtags[] = selected
+    on(session, ts.selected) do selected
         jsdom = r(session, lowertags(selected, ts.selectable))
         htmldom = replace(string(jsdom), "'" => "\\'")
         evaljs(session, js"""
@@ -831,7 +846,8 @@ function tagselector(controller;
         multipletags::Union{Nothing, Bool}=nothing,
     )
     c = gethub(controller)
-    TagSelector(haskey(c, :tag) ? c[:tag] : TagController(c), editabletags, multipletags)
+    controller = haskey(c, :tag) ? c[:tag] : TagController(c)
+    TagSelector(controller, editabletags, multipletags)
 end
 
 mutable struct TrackViewer
@@ -902,10 +918,17 @@ struct FileMenu
     browser::FileBrowser
 end
 
-function filemenu(controller; kwargs...)
+function filemenu(controller; upload_button=false, download_button=false,
+        prepare_download=nothing, create_directory_button=false, delete_button=false,
+        kwargs...)
     wd = getworkingdir(controller)
     dir = joinpath(wd.root, wd.path[])
-    browser = FileBrowser(dir; root=wd.root, upload_button=false)
+    browser = FileBrowser(dir; root=wd.root, upload_button=upload_button,
+                          download_button=download_button,
+                          prepare_download=prepare_download,
+                          create_directory_button=create_directory_button,
+                          delete_button=delete_button)
+    gethub(controller)[:browser] = browser.model
     on(FileBrowsers.selectedfile(browser)) do file
         tryopenfile(controller, file; reload=true)
     end
@@ -920,9 +943,10 @@ function filemenu(controller; kwargs...)
         on(FilePickers.uploadedfile(browser)) do fileinfo
             filepath = joinpath(workingdir[], fileinfo["name"])
             saveinputfile(filepath, fileinfo["content"])
+            notify(workingdir)
         end
     end
-    return FileMenu(gethub(controller), browser)
+    return FileMenu(controller, browser)
 end
 
 function Bonito.jsrender(session::Session, fm::FileMenu)