put the open capability in the url
/tasks, /skills and /processes are one component, so one route pair each and the rows become links. drops the auto-select-items[0] effect: the bare route is the list with nothing open, which is a real state. editing and the just-created flag move to ?edit=1 / ?new=1 — a link row cannot reset them on the way out, and deriving them means navigating to another item clears them for free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
|||||||
|
|
||||||
| ID | file:line | Entity | Proposed route | Note |
|
| ID | file:line | Entity | Proposed route | Note |
|
||||||
|----|-----------|--------|----------------|------|
|
|----|-----------|--------|----------------|------|
|
||||||
| M1 | `Screens/Dashboard/CapabilityPage.tsx:431` | task / skill / process | `/tasks/:dir`, `/skills/:dir`, `/processes/:dir` | one component backs **three** screens (Tasks/Skills/Processes). Highest-value MEDIUM. Also drops the auto-select-`items[0]` effect. |
|
| ~~M1~~ | ~~`Screens/Dashboard/CapabilityPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. |
|
||||||
| M2 | `Screens/Dashboard/TaskLogs/index.tsx:104` | a task-log run | `/task-logs/:id` | detail fetch already keys off the id — clean move. |
|
| M2 | `Screens/Dashboard/TaskLogs/index.tsx:104` | a task-log run | `/task-logs/:id` | detail fetch already keys off the id — clean move. |
|
||||||
| M3 | `Screens/Dashboard/Activity/ActivityScreen.tsx:63,73` | background task / detached job | `/activity/:id` | two row types; unify under one param, screen re-derives `task=`/`path=`. |
|
| M3 | `Screens/Dashboard/Activity/ActivityScreen.tsx:63,73` | background task / detached job | `/activity/:id` | two row types; unify under one param, screen re-derives `task=`/`path=`. |
|
||||||
| M4 | `FileBrowser/.../useFileBrowserApp.ts:269`, `FileItem.tsx:516`, `Breadcrumb.tsx:16`, search-hit `:249` | a folder | `/files?path=<dir>` | **files** already open via `?view=`; **folders** are pure `currentPath` state — no URL, no back/forward. Folder rows + crumbs → `<Link>` on a `?path=` param. |
|
| M4 | `FileBrowser/.../useFileBrowserApp.ts:269`, `FileItem.tsx:516`, `Breadcrumb.tsx:16`, search-hit `:249` | a folder | `/files?path=<dir>` | **files** already open via `?view=`; **folders** are pure `currentPath` state — no URL, no back/forward. Folder rows + crumbs → `<Link>` on a `?path=` param. |
|
||||||
@@ -210,7 +210,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
|||||||
|
|
||||||
### Phase 2 — Add a route, then link (per-entity, medium effort)
|
### Phase 2 — Add a route, then link (per-entity, medium effort)
|
||||||
- [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.**
|
- [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.**
|
||||||
- [ ] **M1** Capabilities → `/tasks|skills|processes/:dir`, rows → `<Link>` (`CapabilityPage.tsx:431`) — covers 3 screens.
|
- [x] **M1** Capabilities → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `CapabilityPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.**
|
||||||
- [ ] **M2** TaskLogs → `/task-logs/:id` (`TaskLogs/index.tsx:104`).
|
- [ ] **M2** TaskLogs → `/task-logs/:id` (`TaskLogs/index.tsx:104`).
|
||||||
- [ ] **M3** Activity → `/activity/:id` (`ActivityScreen.tsx:63,73`).
|
- [ ] **M3** Activity → `/activity/:id` (`ActivityScreen.tsx:63,73`).
|
||||||
- [ ] **M4** FileBrowser folders → `/files?path=`; folder rows + breadcrumbs → `<Link>` (`useFileBrowserApp.ts:269`, `FileItem.tsx`, `Breadcrumb.tsx`).
|
- [ ] **M4** FileBrowser folders → `/files?path=`; folder rows + breadcrumbs → `<Link>` (`useFileBrowserApp.ts:269`, `FileItem.tsx`, `Breadcrumb.tsx`).
|
||||||
|
|||||||
@@ -85,8 +85,11 @@ export function App() {
|
|||||||
|
|
||||||
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
|
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
|
||||||
<Route path="/skills" element={<Dashboard.Skills />} />
|
<Route path="/skills" element={<Dashboard.Skills />} />
|
||||||
|
<Route path="/skills/:dirName" element={<Dashboard.Skills />} />
|
||||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||||
|
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
|
||||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||||
|
<Route path="/processes/:dirName" element={<Dashboard.Processes />} />
|
||||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||||
<Route path="/jobs" element={<Dashboard.JobsPage />} />
|
<Route path="/jobs" element={<Dashboard.JobsPage />} />
|
||||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -27,9 +28,10 @@ type CapabilityListProps = {
|
|||||||
kind: string;
|
kind: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
|
/** Route root for this capability — `/tasks`, `/skills`, `/processes`. Rows link to `<basePath>/<dirName>`. */
|
||||||
|
basePath: string;
|
||||||
selected: string | null;
|
selected: string | null;
|
||||||
onSelect: (dirName: string) => void;
|
onCreate: (dirName: string) => void;
|
||||||
onCreate?: (dirName: string) => void;
|
|
||||||
search?: string;
|
search?: string;
|
||||||
showCreate?: boolean;
|
showCreate?: boolean;
|
||||||
onShowCreateChange?: (value: boolean) => void;
|
onShowCreateChange?: (value: boolean) => void;
|
||||||
@@ -39,6 +41,8 @@ type CapabilityPageProps = {
|
|||||||
kind: string;
|
kind: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
|
/** Route root — given explicitly rather than reused from `endpoint`, which only happens to match. */
|
||||||
|
basePath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CapabilityChatProps = {
|
type CapabilityChatProps = {
|
||||||
@@ -318,8 +322,8 @@ export const CapabilityList = ({
|
|||||||
kind,
|
kind,
|
||||||
endpoint,
|
endpoint,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
basePath,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
|
||||||
onCreate,
|
onCreate,
|
||||||
search: externalSearch,
|
search: externalSearch,
|
||||||
showCreate,
|
showCreate,
|
||||||
@@ -348,7 +352,7 @@ export const CapabilityList = ({
|
|||||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
(onCreate ?? onSelect)(res.dirName);
|
onCreate(res.dirName);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to create ${kind}`);
|
toast.error(`Failed to create ${kind}`);
|
||||||
}
|
}
|
||||||
@@ -429,10 +433,10 @@ export const CapabilityList = ({
|
|||||||
)}
|
)}
|
||||||
<div className="overflow-y-auto flex-1">
|
<div className="overflow-y-auto flex-1">
|
||||||
{filtered.map((item) => (
|
{filtered.map((item) => (
|
||||||
<button
|
<Link
|
||||||
key={item.dirName}
|
key={item.dirName}
|
||||||
onClick={() => onSelect(item.dirName)}
|
to={`${basePath}/${encodeURIComponent(item.dirName)}`}
|
||||||
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
className={`block w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
||||||
selected === item.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
selected === item.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -440,7 +444,7 @@ export const CapabilityList = ({
|
|||||||
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
|
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
|
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
||||||
@@ -453,25 +457,26 @@ export const CapabilityList = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps) => {
|
/**
|
||||||
|
* Backs /tasks, /skills and /processes. Which capability is open is `<basePath>/:dirName` — a route pair
|
||||||
|
* with no redirect guard, because the bare route (the list with nothing open) is a real state and an
|
||||||
|
* unknown dirName gets the empty detail pane rather than a rewritten address.
|
||||||
|
*
|
||||||
|
* The two per-item modes ride along as search params instead of local state: a row is a <Link> now, so it
|
||||||
|
* cannot imperatively reset the panes it changes, and deriving them means navigating to another item drops
|
||||||
|
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
||||||
|
*/
|
||||||
|
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const navigate = useNavigate();
|
||||||
const [editing, setEditing] = useState(false);
|
const selected = useParams<{ dirName: string }>().dirName ?? null;
|
||||||
const [isNew, setIsNew] = useState(false);
|
const [params, setParams] = useSearchParams();
|
||||||
|
const editing = params.get('edit') === '1';
|
||||||
|
const isNew = params.get('new') === '1';
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [showDetail, setShowDetail] = useState(false);
|
|
||||||
|
|
||||||
const { data: items = [] } = useQuery<CapabilitySummary[]>({
|
const setEditing = (on: boolean) => setParams(on ? { edit: '1' } : {}, { replace: true });
|
||||||
queryKey: [queryKey],
|
|
||||||
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (items.length > 0 && !selected) {
|
|
||||||
setSelected(items[0]!.dirName);
|
|
||||||
}
|
|
||||||
}, [items, selected]);
|
|
||||||
|
|
||||||
const { data: detail } = useQuery<CapabilityDetail>({
|
const { data: detail } = useQuery<CapabilityDetail>({
|
||||||
queryKey: [queryKey, selected],
|
queryKey: [queryKey, selected],
|
||||||
@@ -479,28 +484,15 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
|||||||
enabled: !!selected,
|
enabled: !!selected,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectItem = (dirName: string) => {
|
// A brand-new capability is empty, so it opens straight into the chat that fills it in.
|
||||||
setSelected(dirName);
|
const handleCreate = (dirName: string) => navigate(`${basePath}/${encodeURIComponent(dirName)}?edit=1&new=1`);
|
||||||
setShowDetail(true);
|
|
||||||
setIsNew(false);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = (dirName: string) => {
|
|
||||||
setSelected(dirName);
|
|
||||||
setShowDetail(true);
|
|
||||||
setIsNew(true);
|
|
||||||
setEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
try {
|
try {
|
||||||
await client.delete(`${endpoint}/${selected}`);
|
await client.delete(`${endpoint}/${selected}`);
|
||||||
setDeleteConfirm(false);
|
setDeleteConfirm(false);
|
||||||
setEditing(false);
|
navigate(basePath, { replace: true });
|
||||||
setSelected(null);
|
|
||||||
setShowDetail(false);
|
|
||||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Failed to delete ${kind}`);
|
toast.error(`Failed to delete ${kind}`);
|
||||||
@@ -512,35 +504,32 @@ export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps
|
|||||||
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
||||||
{/* Left panel — list */}
|
{/* Left panel — list */}
|
||||||
<Card
|
<Card
|
||||||
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${selected ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||||
>
|
>
|
||||||
<CapabilityList
|
<CapabilityList
|
||||||
kind={kind}
|
kind={kind}
|
||||||
endpoint={endpoint}
|
endpoint={endpoint}
|
||||||
queryKey={queryKey}
|
queryKey={queryKey}
|
||||||
|
basePath={basePath}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelect={selectItem}
|
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right panel — detail + chat */}
|
{/* Right panel — detail + chat. On mobile the two panes swap on `selected`; going back is the bare route. */}
|
||||||
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${selected ? 'flex' : 'hidden md:flex'}`}>
|
||||||
<Card className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
<Card className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
||||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
||||||
<button
|
<Link to={basePath} className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||||
onClick={() => setShowDetail(false)}
|
|
||||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||||
</button>
|
</Link>
|
||||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||||
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
||||||
</span>
|
</span>
|
||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing((e) => !e)}
|
onClick={() => setEditing(!editing)}
|
||||||
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
||||||
>
|
>
|
||||||
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
import { CapabilityPage } from '../CapabilityPage';
|
import { CapabilityPage } from '../CapabilityPage';
|
||||||
|
|
||||||
export const Processes = () => <CapabilityPage kind="Process" endpoint="/processes" queryKey="processes" />;
|
export const Processes = () => (
|
||||||
|
<CapabilityPage kind="Process" endpoint="/processes" queryKey="processes" basePath="/processes" />
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { CapabilityPage } from '../CapabilityPage';
|
import { CapabilityPage } from '../CapabilityPage';
|
||||||
|
|
||||||
export const Skills = () => <CapabilityPage kind="Skill" endpoint="/skills" queryKey="skills" />;
|
export const Skills = () => <CapabilityPage kind="Skill" endpoint="/skills" queryKey="skills" basePath="/skills" />;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { CapabilityPage } from '../CapabilityPage';
|
import { CapabilityPage } from '../CapabilityPage';
|
||||||
|
|
||||||
export const Tasks = () => <CapabilityPage kind="Task" endpoint="/tasks" queryKey="tasks" />;
|
export const Tasks = () => <CapabilityPage kind="Task" endpoint="/tasks" queryKey="tasks" basePath="/tasks" />;
|
||||||
|
|||||||
Reference in New Issue
Block a user