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' }`} >
- {!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() { }`} > +
+ ) : selectedStage ? ( <>
@@ -534,7 +568,7 @@ export default function PromptManager() { }`} > +
+ ) : (

@@ -618,6 +660,7 @@ export default function PromptManager() {

+ )}
)} diff --git a/client/src/pages/Sharing.jsx b/client/src/pages/Sharing.jsx index d252a7b39..dffbe8c61 100644 --- a/client/src/pages/Sharing.jsx +++ b/client/src/pages/Sharing.jsx @@ -8,7 +8,7 @@ */ import { useEffect, useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { Share2, Plus, Trash2, Folder, Inbox, History, Save, Loader2, Check, X, Users, AlertCircle, RefreshCw, Copy, GitMerge, } from 'lucide-react'; @@ -77,7 +77,7 @@ function SharingHeader({ active }) { } export default function Sharing() { - const { section = 'buckets' } = useParams(); + const { section = 'buckets', bucketId } = useParams(); if (section === 'duplicates' || section === 'conflicts') { return (
@@ -86,14 +86,16 @@ export default function Sharing() {
); } - return ; + return ; } -function SharingBuckets() { +// The selected bucket lives in the URL (`/sharing/buckets/:bucketId`) so it's +// deep-linkable and reload-safe. `selectedId` is the route param. +function SharingBuckets({ selectedId }) { + const navigate = useNavigate(); const [buckets, setBuckets] = useState([]); const [, setLocalSchemaVersion] = useState(null); const [loading, setLoading] = useState(true); - const [selectedId, setSelectedId] = useState(null); const [showAdd, setShowAdd] = useState(false); const [form, setForm] = useState(emptyForm); const [creating, setCreating] = useState(false); @@ -120,7 +122,9 @@ function SharingBuckets() { const list = bResp?.buckets || []; setBuckets(list); setLocalSchemaVersion(bResp?.localSchemaVersion ?? null); - if (list.length > 0) setSelectedId(list[0].id); + // Auto-select the first bucket when the URL doesn't already name one, so a + // bare /sharing lands on a populated detail panel (replace: no history spam). + if (!selectedId && list.length > 0) navigate(`/sharing/buckets/${encodeURIComponent(list[0].id)}`, { replace: true }); const display = settings?.sharingDisplayName || ''; const bio = settings?.sharingBio || ''; setSharingDisplayName(display); @@ -193,7 +197,7 @@ function SharingBuckets() { setCreating(false); if (!result?.bucket) return; setBuckets((prev) => [...prev, result.bucket].sort((a, b) => a.name.localeCompare(b.name))); - setSelectedId(result.bucket.id); + navigate(`/sharing/buckets/${encodeURIComponent(result.bucket.id)}`); setForm(emptyForm()); setShowAdd(false); toast.success(`Registered bucket "${result.bucket.name}"`); @@ -202,7 +206,12 @@ function SharingBuckets() { const handleDelete = (bucket) => confirmRemove(() => { const prior = buckets; setBuckets((prev) => prev.filter((b) => b.id !== bucket.id)); - if (selectedId === bucket.id) setSelectedId(prior[0]?.id !== bucket.id ? prior[0]?.id : (prior[1]?.id || null)); + // If the open bucket was removed, fall back to the next remaining one (or the + // index when none are left). + if (selectedId === bucket.id) { + const next = prior.find((b) => b.id !== bucket.id); + navigate(next ? `/sharing/buckets/${encodeURIComponent(next.id)}` : '/sharing'); + } return deleteShareBucket(bucket.id).catch((err) => { toast.error(err.message || 'Failed to remove bucket'); setBuckets(prior); @@ -258,6 +267,8 @@ function SharingBuckets() { }; const selected = buckets.find((b) => b.id === selectedId) || null; + // A bucketId in the URL that isn't in the loaded list (removed / bad deep link). + const bucketNotFound = !!selectedId && !loading && !selected; const displayNameDirty = sharingDisplayName !== savedDisplayName || sharingBio !== savedBio; return ( @@ -432,7 +443,7 @@ function SharingBuckets() {
  • +
  • + ) : !selected ? (
    Pick a bucket on the left to see its inbox + activity.