diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md
index 71bac5ba9..f2bb44fd1 100644
--- a/.changelog/NEXT.md
+++ b/.changelog/NEXT.md
@@ -4,6 +4,10 @@
- **Destructive actions now show a clear "Delete? / Cancel" prompt instead of a hidden second click.** Deleting a round, universe, series, share bucket, issue/episode, or a Writers Room work/folder — and removing an Ask conversation or deleting a world in the Universe Builder — used to silently re-arm the same button on the first click, with nothing on screen telling you a second click was needed. Each of these now pops an explicit inline confirm/cancel affordance right where you clicked, so it's obvious what will happen and easy to back out. The pipeline's "replace existing scenes / pages / audio lines" extract buttons got the same treatment (an inline "Replace? / Cancel" row) instead of arming via a fleeting toast. Delete/confirm controls in the Writers Room library were also enlarged to a comfortable 44px touch target for mobile.
+## Deep-linkable selections
+
+- **Picking an author, music artist/album/track, share bucket, prompt stage/variable, or JIRA report now lives in the URL — so it's shareable, bookmarkable, reload-safe, and reachable from ⌘K / voice / the back button (#2025).** These master-detail pages previously kept the open record in local state, so a pasted link or a reload always dropped you back on an empty list. Authors now open at `/authors/:id` (`/authors/new` to create), the Music tabs at `/music/:tab/:id`, Sharing buckets at `/sharing/buckets/:bucketId`, the Prompt Manager via `?stage=` / `?var=`, and JIRA Reports via `?reportApp=&reportDate=`. Deleting or clearing a selection returns to the index, and a stale/deleted id now shows a "could not be found" fallback instead of a blank pane. (OpenClaw's session picker stays local by design — its sessions are ephemeral runtime state, not user-owned records.)
+
## Accessibility
- **Config form labels now focus their field when clicked and read correctly to screen readers.** Many settings/config forms (AI Providers, DataDog, feature-agent config, message & calendar account setup, scheduled-task provider/model pickers, the agent world/schedule tabs, MeatSpace nicotine + POST drills, and more) rendered the label as a plain sibling of its input with no association, so clicking the label did nothing and assistive tech couldn't announce the pairing. These fields now flow through a shared `FormField` wrapper that generates a stable id and wires `htmlFor`/`id` automatically, keeping the exact same styling (#2027). Remaining forms are tracked for a follow-up sweep (#2051).
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 0f63722f4..cbd029a60 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -326,14 +326,23 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
} />
} />
+ {/* Authors is a single master-detail page: the index + editor share one
+ component, so selection rides `:authorId` (which also captures the
+ `new` create-mode sentinel — author ids are UUIDs, so it can't
+ collide). A bare `/authors` is the idle index. */}
} />
+ } />
+ {/* Music is tabbed (`:tab`); each tab's master-detail selection lives
+ at `/music/:tab/:id` (`new` = create sentinel). */}
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/components/music/AlbumsManager.jsx b/client/src/components/music/AlbumsManager.jsx
index ba6962a92..3dcc78d48 100644
--- a/client/src/components/music/AlbumsManager.jsx
+++ b/client/src/components/music/AlbumsManager.jsx
@@ -9,6 +9,7 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
import { Plus, Loader2, Trash2, Save, Upload, ImageIcon, Sparkles, X, ArrowUp, ArrowDown } from 'lucide-react';
import toast from '../ui/Toast';
import GalleryImagePicker from '../imageGen/GalleryImagePicker';
@@ -65,10 +66,13 @@ function Field({ label, hint, children }) {
}
export default function AlbumsManager() {
+ const navigate = useNavigate();
+ // Selection lives in the URL (`/music/albums/:id`, `/music/albums/new`) so it's
+ // deep-linkable and reload-safe. `id === 'new'` = create; a real id = edit.
+ const { id } = useParams();
const [albums, setAlbums] = useState([]);
const [allTracks, setAllTracks] = useState([]);
const [loading, setLoading] = useState(true);
- const [selectedId, setSelectedId] = useState(null); // 'new' | id | null
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -109,26 +113,33 @@ export default function AlbumsManager() {
}
}, [genJobId, gen.status, gen.filename, gen.path, gen.error]);
- const isCreate = selectedId === 'new';
+ const isCreate = id === 'new';
const selected = useMemo(
- () => (isCreate || !selectedId ? null : albums.find((a) => a.id === selectedId) || null),
- [albums, selectedId, isCreate],
+ () => (isCreate || !id ? null : albums.find((a) => a.id === id) || null),
+ [albums, id, isCreate],
);
+ const notFound = !isCreate && !!id && !loading && !selected;
const tracksById = useMemo(() => new Map(allTracks.map((t) => [t.id, t])), [allTracks]);
const canGenerate = !!(form.title.trim() || form.genre.trim() || form.description.trim());
- const selectAlbum = (a) => {
- setSelectedId(a.id);
- setForm(formFromAlbum(a));
- setConfirmDelete(false);
- clearGeneration();
- };
- const startCreate = () => {
- setSelectedId('new');
- setForm(emptyForm());
+ const selectAlbum = (a) => navigate(`/music/albums/${encodeURIComponent(a.id)}`);
+ const startCreate = () => navigate('/music/albums/new');
+
+ // Hydrate the editor form from the URL-selected album. Keyed on the id so a
+ // list refresh doesn't clobber the open form; resets run for every selection
+ // change (incl. idle / not-found) so a stray render can't land on the previous
+ // album (see Authors.jsx for the base pattern).
+ const hydratedRef = useRef(null);
+ const selectionKey = id ?? null;
+ useEffect(() => {
+ if (loading) return;
+ if (hydratedRef.current === selectionKey) return;
+ hydratedRef.current = selectionKey;
setConfirmDelete(false);
clearGeneration();
- };
+ if (isCreate) setForm(emptyForm());
+ else if (selected) setForm(formFromAlbum(selected));
+ }, [selectionKey, isCreate, selected, loading]);
const handleGenerateCover = async () => {
if (isGenerating || uploadingCover) return;
@@ -216,14 +227,14 @@ export default function AlbumsManager() {
setSaving(false);
if (!created) return;
setAlbums((prev) => [...prev, created].sort((a, b) => (a.title || '').localeCompare(b.title || '')));
- setSelectedId(created.id);
+ navigate(`/music/albums/${encodeURIComponent(created.id)}`);
toast.success(`Created "${created.title}"`);
} else {
// Did the track list actually change vs the loaded record?
const original = selected?.trackIds || [];
const trackIdsChanged = original.length !== form.trackIds.length
|| original.some((id, i) => id !== form.trackIds[i]);
- const updated = await updateAlbum(selectedId, buildPayload({ includeTrackIds: trackIdsChanged })).catch((err) => { toast.error(err.message || 'Failed to save album'); return null; });
+ const updated = await updateAlbum(id, buildPayload({ includeTrackIds: trackIdsChanged })).catch((err) => { toast.error(err.message || 'Failed to save album'); return null; });
setSaving(false);
if (!updated) return;
setAlbums((prev) => prev.map((a) => (a.id === updated.id ? updated : a)).sort((a, b) => (a.title || '').localeCompare(b.title || '')));
@@ -235,8 +246,8 @@ export default function AlbumsManager() {
if (!selected) return;
const prior = albums;
setAlbums((prev) => prev.filter((a) => a.id !== selected.id));
- setSelectedId(null);
setConfirmDelete(false);
+ navigate('/music/albums');
await deleteAlbum(selected.id).catch((err) => { toast.error(err.message || 'Delete failed'); setAlbums(prior); });
};
@@ -272,7 +283,7 @@ export default function AlbumsManager() {
type="button"
onClick={() => selectAlbum(a)}
className={`w-full text-left px-2 py-2 rounded text-sm flex items-center gap-2 ${
- a.id === selectedId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
+ a.id === id ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
}`}
>
{a.coverImageUrl ? (
@@ -292,7 +303,14 @@ export default function AlbumsManager() {
- {!isCreate && !selected ? (
+ {notFound ? (
+
+ That album could not be found — it may have been deleted.{' '}
+
+
+ ) : !isCreate && !selected ? (
Select an album to edit, or create a new one.
) : (
diff --git a/client/src/components/music/ArtistsManager.jsx b/client/src/components/music/ArtistsManager.jsx
index 13cc94d2f..96b25e1a1 100644
--- a/client/src/components/music/ArtistsManager.jsx
+++ b/client/src/components/music/ArtistsManager.jsx
@@ -8,6 +8,7 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
import { Plus, Loader2, Trash2, Save, Upload, ImageIcon, Sparkles, X } from 'lucide-react';
import toast from '../ui/Toast';
import GalleryImagePicker from '../imageGen/GalleryImagePicker';
@@ -58,10 +59,13 @@ function Field({ label, hint, children }) {
}
export default function ArtistsManager() {
+ const navigate = useNavigate();
+ // Selection lives in the URL (`/music/artists/:id`, `/music/artists/new`) so
+ // it's deep-linkable and reload-safe. `id === 'new'` is create mode; a real id
+ // is edit mode; absent is idle.
+ const { id } = useParams();
const [artists, setArtists] = useState([]);
const [loading, setLoading] = useState(true);
- // selectedId === 'new' is create mode; a real id is edit mode; null is idle.
- const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -164,26 +168,32 @@ export default function ArtistsManager() {
.finally(() => setLoading(false));
}, []);
- const isCreate = selectedId === 'new';
+ const isCreate = id === 'new';
const selected = useMemo(
- () => (isCreate || !selectedId ? null : artists.find((a) => a.id === selectedId) || null),
- [artists, selectedId, isCreate],
+ () => (isCreate || !id ? null : artists.find((a) => a.id === id) || null),
+ [artists, id, isCreate],
);
+ const notFound = !isCreate && !!id && !loading && !selected;
const canGenerate = !!(form.physicalDescription.trim() || form.portraitStyle.trim());
- const selectArtist = (a) => {
- setSelectedId(a.id);
- setForm(formFromArtist(a));
- setConfirmDelete(false);
- clearGeneration();
- };
+ const selectArtist = (a) => navigate(`/music/artists/${encodeURIComponent(a.id)}`);
+ const startCreate = () => navigate('/music/artists/new');
- const startCreate = () => {
- setSelectedId('new');
- setForm(emptyForm());
+ // Hydrate the editor form from the URL-selected artist. Keyed on the id so a
+ // list refresh doesn't clobber the open form; resets run for every selection
+ // change (incl. idle / not-found) so a stray render can't land on the previous
+ // artist (see Authors.jsx for the base pattern).
+ const hydratedRef = useRef(null);
+ const selectionKey = id ?? null;
+ useEffect(() => {
+ if (loading) return;
+ if (hydratedRef.current === selectionKey) return;
+ hydratedRef.current = selectionKey;
setConfirmDelete(false);
clearGeneration();
- };
+ if (isCreate) setForm(emptyForm());
+ else if (selected) setForm(formFromArtist(selected));
+ }, [selectionKey, isCreate, selected, loading]);
const handleSave = async () => {
const name = form.name.trim();
@@ -198,10 +208,10 @@ export default function ArtistsManager() {
setSaving(false);
if (!created) return;
setArtists((prev) => [...prev, created].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
- setSelectedId(created.id);
+ navigate(`/music/artists/${encodeURIComponent(created.id)}`);
toast.success(`Created "${created.name}"`);
} else {
- const updated = await updateArtist(selectedId, payload).catch((err) => {
+ const updated = await updateArtist(id, payload).catch((err) => {
toast.error(err.message || 'Failed to save artist');
return null;
});
@@ -218,8 +228,8 @@ export default function ArtistsManager() {
if (!selected) return;
const prior = artists;
setArtists((prev) => prev.filter((a) => a.id !== selected.id));
- setSelectedId(null);
setConfirmDelete(false);
+ navigate('/music/artists');
await deleteArtist(selected.id).catch((err) => {
toast.error(err.message || 'Delete failed');
setArtists(prior);
@@ -257,7 +267,7 @@ export default function ArtistsManager() {
type="button"
onClick={() => selectArtist(a)}
className={`w-full text-left px-3 py-2 rounded text-sm truncate ${
- a.id === selectedId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
+ a.id === id ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
}`}
>
{a.name}
@@ -270,7 +280,14 @@ export default function ArtistsManager() {
- {!isCreate && !selected ? (
+ {notFound ? (
+
+ That artist could not be found — it may have been deleted.{' '}
+
+
+ ) : !isCreate && !selected ? (
Select an artist to edit, or create a new one.
) : (
diff --git a/client/src/components/music/TracksManager.jsx b/client/src/components/music/TracksManager.jsx
index 3d2582b4f..0ca879e29 100644
--- a/client/src/components/music/TracksManager.jsx
+++ b/client/src/components/music/TracksManager.jsx
@@ -13,6 +13,7 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
import { Plus, Loader2, Trash2, Save, Upload, Music2, Library } from 'lucide-react';
import toast from '../ui/Toast';
import { formatTimecode } from '../../utils/formatters';
@@ -55,9 +56,12 @@ function Field({ label, hint, children }) {
}
export default function TracksManager() {
+ const navigate = useNavigate();
+ // Selection lives in the URL (`/music/tracks/:id`, `/music/tracks/new`) so it's
+ // deep-linkable and reload-safe. `id === 'new'` = create; a real id = edit.
+ const { id } = useParams();
const [tracks, setTracks] = useState([]);
const [loading, setLoading] = useState(true);
- const [selectedId, setSelectedId] = useState(null); // 'new' | id | null
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -90,11 +94,12 @@ export default function TracksManager() {
.finally(() => setLoading(false));
}, []);
- const isCreate = selectedId === 'new';
+ const isCreate = id === 'new';
const selected = useMemo(
- () => (isCreate || !selectedId ? null : tracks.find((t) => t.id === selectedId) || null),
- [tracks, selectedId, isCreate],
+ () => (isCreate || !id ? null : tracks.find((t) => t.id === id) || null),
+ [tracks, id, isCreate],
);
+ const notFound = !isCreate && !!id && !loading && !selected;
// The persisted track (for gen metadata + the audio player, which need a saved id).
const persisted = selected;
@@ -110,19 +115,26 @@ export default function TracksManager() {
setRemix(null);
};
- const selectTrack = (t) => {
- setSelectedId(t.id);
- selectedIdRef.current = t.id;
- setForm(formFromTrack(t));
- resetTrackViewState();
- };
-
- const startCreate = () => {
- setSelectedId('new');
- selectedIdRef.current = 'new';
- setForm(emptyForm());
+ const selectTrack = (t) => navigate(`/music/tracks/${encodeURIComponent(t.id)}`);
+ const startCreate = () => navigate('/music/tracks/new');
+
+ // Hydrate the editor form from the URL-selected track. Keyed on the id so a
+ // list refresh (audio upload, generate, upsertLocal) doesn't clobber the open
+ // form; `selectedIdRef` is kept in sync so async handlers can still detect a
+ // selection change that happened mid-round-trip. Per-track view state is reset
+ // for every selection change (incl. idle / not-found) so a modal/remix left
+ // open can't drive the previous track (see Authors.jsx for the base pattern).
+ const hydratedRef = useRef(null);
+ const selectionKey = id ?? null;
+ useEffect(() => {
+ if (loading) return;
+ if (hydratedRef.current === selectionKey) return;
+ hydratedRef.current = selectionKey;
+ selectedIdRef.current = selectionKey;
resetTrackViewState();
- };
+ if (isCreate) setForm(emptyForm());
+ else if (selected) setForm(formFromTrack(selected));
+ }, [selectionKey, isCreate, selected, loading]);
const upsertLocal = (track) => {
setTracks((prev) => {
@@ -140,8 +152,10 @@ export default function TracksManager() {
setSaving(false);
if (!created) return;
upsertLocal(created);
- setSelectedId(created.id);
+ // Point the async-handler ref at the new id immediately (the hydration
+ // effect also sets it, but not until after navigation re-renders).
selectedIdRef.current = created.id;
+ navigate(`/music/tracks/${encodeURIComponent(created.id)}`);
toast.success(`Created "${created.title}"`);
} else {
// Drop `albumId` from a metadata-only update unless the user actually
@@ -151,7 +165,7 @@ export default function TracksManager() {
// remains the primary place to (re)order an album's tracks.
const payload = { ...form, title };
if ((selected?.albumId || '') === form.albumId) delete payload.albumId;
- const updated = await updateTrack(selectedId, payload).catch((err) => { toast.error(err.message || 'Failed to save track'); return null; });
+ const updated = await updateTrack(id, payload).catch((err) => { toast.error(err.message || 'Failed to save track'); return null; });
setSaving(false);
if (!updated) return;
upsertLocal(updated);
@@ -163,8 +177,8 @@ export default function TracksManager() {
if (!selected) return;
const prior = tracks;
setTracks((prev) => prev.filter((t) => t.id !== selected.id));
- setSelectedId(null);
resetTrackViewState();
+ navigate('/music/tracks');
await deleteTrack(selected.id).catch((err) => { toast.error(err.message || 'Delete failed'); setTracks(prior); });
};
@@ -298,7 +312,7 @@ export default function TracksManager() {
type="button"
onClick={() => selectTrack(t)}
className={`w-full text-left px-3 py-2 rounded text-sm truncate flex items-center gap-2 ${
- t.id === selectedId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
+ t.id === id ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
}`}
>
@@ -312,7 +326,14 @@ export default function TracksManager() {
- {!isCreate && !selected ? (
+ {notFound ? (
+
+ That track could not be found — it may have been deleted.{' '}
+
+
+ ) : !isCreate && !selected ? (
Select a track to edit, or create a new one.
) : (
diff --git a/client/src/pages/Authors.jsx b/client/src/pages/Authors.jsx
index 452d00fd6..d7a757c0e 100644
--- a/client/src/pages/Authors.jsx
+++ b/client/src/pages/Authors.jsx
@@ -11,6 +11,7 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
import { FilePen, Plus, Loader2, Trash2, Save, Upload, ImageIcon, Sparkles, X } from 'lucide-react';
import toast from '../components/ui/Toast';
import GalleryImagePicker from '../components/imageGen/GalleryImagePicker';
@@ -61,10 +62,13 @@ function Field({ label, hint, children }) {
}
export default function Authors() {
+ const navigate = useNavigate();
+ // Selection lives in the URL (`/authors/:authorId`, `/authors/new`) so it's
+ // deep-linkable, reload-safe, and reachable from ⌘K/voice. `authorId === 'new'`
+ // is create mode; a real id is edit mode; absent is idle.
+ const { authorId } = useParams();
const [authors, setAuthors] = useState([]);
const [loading, setLoading] = useState(true);
- // selectedId === 'new' is create mode; a real id is edit mode; null is idle.
- const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -184,27 +188,37 @@ export default function Authors() {
.finally(() => setLoading(false));
}, []);
- const isCreate = selectedId === 'new';
+ const isCreate = authorId === 'new';
const selected = useMemo(
- () => (isCreate || !selectedId ? null : authors.find((a) => a.id === selectedId) || null),
- [authors, selectedId, isCreate],
+ () => (isCreate || !authorId ? null : authors.find((a) => a.id === authorId) || null),
+ [authors, authorId, isCreate],
);
+ // A real id that isn't in the loaded list (deleted / bad deep link) → not-found.
+ const notFound = !isCreate && !!authorId && !loading && !selected;
// A headshot render needs at least a subject or an art-direction prompt.
const canGenerate = !!(form.physicalDescription.trim() || form.headshotStyle.trim());
- const selectAuthor = (a) => {
- setSelectedId(a.id);
- setForm(formFromAuthor(a));
- setConfirmDelete(false);
- clearGeneration();
- };
+ const selectAuthor = (a) => navigate(`/authors/${encodeURIComponent(a.id)}`);
+ const startCreate = () => navigate('/authors/new');
- const startCreate = () => {
- setSelectedId('new');
- setForm(emptyForm());
+ // Hydrate the editor form from the URL-selected author. Keyed on the id so a
+ // list refresh (create/update/delete mutating `authors`) doesn't clobber the
+ // open form; `hydratedRef` tracks which selection is already loaded. Resets
+ // (confirm-delete + any in-flight generation) run for EVERY selection change —
+ // including navigating to the idle index or a stale id — so a stray render
+ // can't land on the previous author after you've moved on.
+ const hydratedRef = useRef(null);
+ const selectionKey = authorId ?? null;
+ useEffect(() => {
+ if (loading) return;
+ if (hydratedRef.current === selectionKey) return;
+ hydratedRef.current = selectionKey;
setConfirmDelete(false);
clearGeneration();
- };
+ if (isCreate) setForm(emptyForm());
+ else if (selected) setForm(formFromAuthor(selected));
+ // else: idle / not-found — leave the form; the fallback UI handles it.
+ }, [selectionKey, isCreate, selected, loading]);
const handleSave = async () => {
const name = form.name.trim();
@@ -219,10 +233,10 @@ export default function Authors() {
setSaving(false);
if (!created) return;
setAuthors((prev) => [...prev, created].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
- setSelectedId(created.id);
+ navigate(`/authors/${encodeURIComponent(created.id)}`);
toast.success(`Created "${created.name}"`);
} else {
- const updated = await updateAuthor(selectedId, payload).catch((err) => {
+ const updated = await updateAuthor(authorId, payload).catch((err) => {
toast.error(err.message || 'Failed to save author');
return null;
});
@@ -239,8 +253,8 @@ export default function Authors() {
if (!selected) return;
const prior = authors;
setAuthors((prev) => prev.filter((a) => a.id !== selected.id));
- setSelectedId(null);
setConfirmDelete(false);
+ navigate('/authors');
await deleteAuthor(selected.id).catch((err) => {
toast.error(err.message || 'Delete failed');
setAuthors(prior);
@@ -284,7 +298,7 @@ export default function Authors() {
type="button"
onClick={() => selectAuthor(a)}
className={`w-full text-left px-3 py-2 rounded text-sm truncate ${
- a.id === selectedId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
+ a.id === authorId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
}`}
>
{a.name}
@@ -296,7 +310,14 @@ export default function Authors() {
- {!isCreate && !selected ? (
+ {notFound ? (
+
+ That author could not be found — it may have been deleted.{' '}
+
+
+ ) : !isCreate && !selected ? (
Select an author to edit, or create a new one.
) : (
diff --git a/client/src/pages/Authors.test.jsx b/client/src/pages/Authors.test.jsx
index 836323a93..a562a1287 100644
--- a/client/src/pages/Authors.test.jsx
+++ b/client/src/pages/Authors.test.jsx
@@ -1,7 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
import Authors from './Authors';
+// Selection is URL-driven (`/authors/:authorId`, `/authors/new`), so mount the
+// page inside a router exposing those routes; navigation drives the editor.
+const renderAuthors = () => render(
+
+
+ } />
+ } />
+
+ ,
+);
+
const listAuthors = vi.fn();
const generateImage = vi.fn();
@@ -49,7 +61,7 @@ describe('Authors headshot generation', () => {
});
const openCreateForm = async () => {
- render();
+ renderAuthors();
await screen.findByText(/No authors yet/i);
fireEvent.click(screen.getByRole('button', { name: /New Author/i }));
};
@@ -135,7 +147,7 @@ describe('Authors headshot generation', () => {
{ id: 'a1', name: 'Alice', headshotImageUrl: '' },
{ id: 'a2', name: 'Bob', headshotImageUrl: '' },
]);
- render();
+ renderAuthors();
fireEvent.click(await screen.findByRole('button', { name: 'Alice' }));
fireEvent.change(screen.getByPlaceholderText(/silver-streaked dark hair/i), {
@@ -164,7 +176,7 @@ describe('Authors headshot generation', () => {
{ id: 'a1', name: 'Alice', headshotImageUrl: '' },
{ id: 'a2', name: 'Bob', headshotImageUrl: '' },
]);
- render();
+ renderAuthors();
fireEvent.click(await screen.findByRole('button', { name: 'Alice' }));
fireEvent.change(screen.getByPlaceholderText(/silver-streaked dark hair/i), {
diff --git a/client/src/pages/JiraReports.jsx b/client/src/pages/JiraReports.jsx
index 9567af35c..e20833005 100644
--- a/client/src/pages/JiraReports.jsx
+++ b/client/src/pages/JiraReports.jsx
@@ -129,12 +129,42 @@ export default function JiraReports() {
const [apps, setApps] = useState([]);
const filterAppId = searchParams.get('app') || '';
+ // The open report lives in the URL (`?reportApp=&reportDate=`) so it's
+ // deep-linkable and reload-safe. appId + date is the report's stable id.
+ const reportApp = searchParams.get('reportApp') || '';
+ const reportDate = searchParams.get('reportDate') || '';
useEffect(() => {
loadReports();
loadApps();
}, []);
+ // Restore the URL-selected report: fetch the full record when the params name
+ // one that isn't already loaded, and clear the selection when they're absent.
+ // A stale id that no longer resolves leaves selectedReport null → the detail
+ // pane falls back to its "Select a report" placeholder.
+ useEffect(() => {
+ if (!reportApp || !reportDate) { setSelectedReport(null); return; }
+ if (selectedReport?.appId === reportApp && selectedReport?.date === reportDate) return;
+ let cancelled = false;
+ api.getJiraReport(reportApp, reportDate).then((full) => {
+ // Clear the selection when the URL names a report that no longer resolves
+ // (deleted / bad deep link) so the detail pane falls back to its
+ // placeholder instead of stranding the previously-open report.
+ if (!cancelled) setSelectedReport(full?.appId ? full : null);
+ }).catch(() => { if (!cancelled) setSelectedReport(null); });
+ return () => { cancelled = true; };
+ // selectedReport intentionally omitted — the guard above prevents refetch loops.
+ }, [reportApp, reportDate]);
+
+ const selectReport = (appId, date) => {
+ const p = new URLSearchParams(searchParams);
+ p.set('reportApp', appId);
+ p.set('reportDate', date);
+ // Push (not replace) so Back returns to the previously-open report.
+ setSearchParams(p);
+ };
+
const loadApps = async () => {
const allApps = await api.getApps();
const jiraApps = (allApps || []).filter(a => a.jira?.enabled);
@@ -155,24 +185,21 @@ export default function JiraReports() {
toast.success(appId ? 'Report generated' : `Generated ${Array.isArray(result) ? result.length : 1} report(s)`);
await loadReports();
if (!Array.isArray(result) && result.appId) {
+ // Set the full record directly (avoids a redundant re-fetch — the effect's
+ // guard sees the params already match) and reflect it in the URL.
setSelectedReport(result);
+ selectReport(result.appId, result.date);
}
}
setGenerating(false);
};
- const handleSelectReport = async (reportMeta) => {
- const full = await api.getJiraReport(reportMeta.appId, reportMeta.date);
- if (full) setSelectedReport(full);
- };
+ const handleSelectReport = (reportMeta) => selectReport(reportMeta.appId, reportMeta.date);
const handleFilterApp = (appId) => {
- if (appId) {
- setSearchParams({ app: appId });
- } else {
- setSearchParams({});
- }
- setSelectedReport(null);
+ // Changing the project filter drops the open report (its params too) so a
+ // filtered-out selection can't linger.
+ setSearchParams(appId ? { app: appId } : {});
};
const filteredReports = filterAppId
diff --git a/client/src/pages/OpenClaw.jsx b/client/src/pages/OpenClaw.jsx
index 12236e533..26ef36a3c 100644
--- a/client/src/pages/OpenClaw.jsx
+++ b/client/src/pages/OpenClaw.jsx
@@ -1,3 +1,21 @@
+/**
+ * OpenClaw — operator chat surface (streaming, context, attachments).
+ *
+ * Deep-linking exemption (issue #2025): `selectedSessionId` is intentionally
+ * NOT encoded in the URL as a routable record. Unlike the master-detail record
+ * views (Authors/Music/Sharing/Prompts) that this issue converts to id'd routes,
+ * an OpenClaw session is ephemeral runtime state, not a user-owned record:
+ * - The session list is discovered from an external OpenClaw runtime and can
+ * change on every Refresh; sessions come and go with the runtime.
+ * - Selection is runtime-managed, not user-owned: `loadRuntime` auto-picks a
+ * `preferredSessionId` (last selection → runtime `defaultSession` → first
+ * session) on every load/reconnect, so the runtime — not a pasted URL — is
+ * the source of truth for "what's open."
+ * - The active session is wired into live SSE streaming state (useOpenClawStream)
+ * with in-flight refs; driving it from a URL param would fight that lifecycle.
+ * This mirrors the transient-session rationale for other socket/stream surfaces.
+ * If OpenClaw ever gains a persistent, user-owned session registry, revisit this.
+ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
diff --git a/client/src/pages/PromptManager.jsx b/client/src/pages/PromptManager.jsx
index 9ce4f71d3..55d91585d 100644
--- a/client/src/pages/PromptManager.jsx
+++ b/client/src/pages/PromptManager.jsx
@@ -27,6 +27,20 @@ export default function PromptManager() {
else p.set('tab', next);
setSearchParams(p, { replace: true });
};
+ // Selected stage / variable live in the URL (`?stage=` / `?var=`) so the open
+ // record is deep-linkable and reload-safe. Unlike the `tab` param above (a view
+ // toggle that uses replace), record selection is a PUSH so Back returns to the
+ // previously-open record — matching Authors/Music/Sharing.
+ const setParam = (key, val) => {
+ const p = new URLSearchParams(searchParams);
+ if (val == null || val === '') p.delete(key);
+ else p.set(key, val);
+ setSearchParams(p);
+ };
+ const selectedStage = searchParams.get('stage');
+ const selectedVar = searchParams.get('var');
+ const setSelectedStage = (name) => setParam('stage', name);
+ const setSelectedVar = (key) => setParam('var', key);
const [stages, setStages] = useState({});
const [variables, setVariables] = useState({});
const [loading, setLoading] = useState(true);
@@ -38,14 +52,12 @@ export default function PromptManager() {
'memory-evaluate', 'app-detection'
];
- // Stage editing
- const [selectedStage, setSelectedStage] = useState(null);
+ // Stage editing (selection is URL-driven — see selectedStage above)
const [stageTemplate, setStageTemplate] = useState('');
const [stageConfig, setStageConfig] = useState({});
const [preview, setPreview] = useState('');
- // Variable editing
- const [selectedVar, setSelectedVar] = useState(null);
+ // Variable editing (selection is URL-driven — see selectedVar above)
const [varForm, setVarForm] = useState({ key: '', name: '', category: '', content: '' });
// Stage creation
@@ -92,25 +104,34 @@ export default function PromptManager() {
setLoading(false);
};
- const loadStage = async (name) => {
- setSelectedStage(name);
- const res = await fetch(`/api/prompts/${name}`).then(r => r.json());
- setStageTemplate(res.template || '');
- // Normalize a server-returned timeout via parseTimeoutMs so the editor
- // shares the validator's accept set: integers OR digit-only strings
- // (e.g. legacy `'900000'` from pre-validation installs) round-trip
- // through the UI, while non-positive / non-integer / garbage values
- // (0, 'abc', undefined, 1.5) collapse to null so the input doesn't
- // surface them as touched. Only set the key when the server actually
- // shipped a value — otherwise the next save would write `timeout: null`
- // (the server's explicit-clear sentinel) for stages the user never
- // touched, conflating "key absent" with "user cleared the override".
- const cfg = { name: res.name, description: res.description, model: res.model, provider: res.provider || null, variables: res.variables || [] };
- const timeout = parseTimeoutMs(res.timeout);
- if (timeout !== null) cfg.timeout = timeout;
- setStageConfig(cfg);
- setPreview('');
- };
+ // Fetch the URL-selected stage's template + config. Keyed on selectedStage so
+ // a deep link / reload restores the open editor; a cleared param resets it.
+ useEffect(() => {
+ if (!selectedStage) { setStageTemplate(''); setStageConfig({}); setPreview(''); return; }
+ let cancelled = false;
+ fetch(`/api/prompts/${selectedStage}`)
+ .then(r => (r.ok ? r.json() : null))
+ .then(res => {
+ if (cancelled || !res) return;
+ setStageTemplate(res.template || '');
+ // Normalize a server-returned timeout via parseTimeoutMs so the editor
+ // shares the validator's accept set: integers OR digit-only strings
+ // (e.g. legacy `'900000'` from pre-validation installs) round-trip
+ // through the UI, while non-positive / non-integer / garbage values
+ // (0, 'abc', undefined, 1.5) collapse to null so the input doesn't
+ // surface them as touched. Only set the key when the server actually
+ // shipped a value — otherwise the next save would write `timeout: null`
+ // (the server's explicit-clear sentinel) for stages the user never
+ // touched, conflating "key absent" with "user cleared the override".
+ const cfg = { name: res.name, description: res.description, model: res.model, provider: res.provider || null, variables: res.variables || [] };
+ const timeout = parseTimeoutMs(res.timeout);
+ if (timeout !== null) cfg.timeout = timeout;
+ setStageConfig(cfg);
+ setPreview('');
+ })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [selectedStage]);
const saveStage = async () => {
setSaving(true);
@@ -138,11 +159,16 @@ export default function PromptManager() {
setPreview(data.preview);
};
- const loadVariable = (key) => {
- setSelectedVar(key);
- const v = variables[key];
- setVarForm({ key, name: v.name || '', category: v.category || '', content: v.content || '' });
- };
+ // Hydrate the variable editor from the URL-selected key. Keyed on selectedVar
+ // (+ variables so it fills in once the list loads) — no param means create mode.
+ useEffect(() => {
+ if (selectedVar && variables[selectedVar]) {
+ const v = variables[selectedVar];
+ setVarForm({ key: selectedVar, name: v.name || '', category: v.category || '', content: v.content || '' });
+ } else if (!selectedVar) {
+ setVarForm({ key: '', name: '', category: '', content: '' });
+ }
+ }, [selectedVar, variables]);
const saveVariable = async () => {
setSaving(true);
@@ -163,6 +189,7 @@ export default function PromptManager() {
const deleteVariable = async (key) => {
const res = await fetch(`/api/prompts/variables/${key}`, { method: 'DELETE' });
if (!res.ok) { toast.error('Failed to delete variable: ' + await res.text()); return; }
+ if (selectedVar === key) setSelectedVar(null);
await loadData();
};
@@ -365,7 +392,7 @@ export default function PromptManager() {
}`}
>