Skip to content

Commit 9df25cf

Browse files
committed
feat: switch bots from the header without losing the tab
- Add `botRoutes.ts` with shared routing constants and helpers for bot detail tabs - Create `BotSwitcher` component in header that lets you jump between bots while preserving the current tab via `?tab=` query param - Move tab state from component state to URL search params so switching bots keeps you on the same section (e.g., Logs stays Logs) - Wrap `BotDetail` in `BotDetailPage` with a `key` to reset component state per bot name, preventing settings and tools from leaking across bots - Convert tab navigation buttons to `Link` elements using `botPath()` helper - Update `AddBot` to use `botPath()` when redirecting to the new bot, landing on Tools if Permit2 approval is needed - Import `useSearchParams` in `BotDetail` to sync tab from URL and handle fallback to settings or tools based on creation context
1 parent 36dda08 commit 9df25cf

9 files changed

Lines changed: 224 additions & 35 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
a165f4c554b812b6d35482eaea0ac03f80be614c
1+
9f52cc0f99aee4decfd8247f2a93f3c3e59baf7b

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.218
1+
0.1.219

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.218"
3+
version = "0.1.219"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; quotes Swap via RFQ firm quotes and optionally fills resting limit orders."
66
license = "AGPL-3.0-or-later"

web/src/App.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useState } from 'react'
2-
import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom'
2+
import { Link, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'
33
import { ApiError, api, setUnauthorizedHandler } from './api'
44
import OverflowMenu from './components/OverflowMenu'
55
import TextileIcon from './components/TextileIcon'
@@ -80,7 +80,7 @@ export default function App() {
8080
<Routes>
8181
<Route path="/" element={<Fleet />} />
8282
<Route path="/add" element={<AddBot rfqDefault={session.rfqDefault} />} />
83-
<Route path="/bots/:name" element={<BotDetail />} />
83+
<Route path="/bots/:name" element={<BotDetailPage />} />
8484
<Route path="*" element={<Navigate to="/" replace />} />
8585
</Routes>
8686
</main>
@@ -91,6 +91,12 @@ export default function App() {
9191
)
9292
}
9393

94+
/** Fresh instance per bot so settings, tools, and banners don't leak across names. */
95+
function BotDetailPage() {
96+
const { name } = useParams()
97+
return <BotDetail key={name} />
98+
}
99+
94100
function Header({
95101
session,
96102
theme,

web/src/botRoutes.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Bot detail URL: `/bots/:name?tab=…`. Shared so the header switcher and the
2+
// page itself keep the same tab when jumping between bots.
3+
4+
export const BOT_TABS = ['settings', 'dashboard', 'config', 'logs', 'tools'] as const
5+
export type BotTab = (typeof BOT_TABS)[number]
6+
7+
export const TAB_LABEL: Record<BotTab, string> = {
8+
settings: 'Settings',
9+
dashboard: 'Dashboard',
10+
config: 'Raw config',
11+
logs: 'Logs',
12+
tools: 'Tools',
13+
}
14+
15+
export function isBotTab(value: string | null | undefined): value is BotTab {
16+
return !!value && (BOT_TABS as readonly string[]).includes(value)
17+
}
18+
19+
export function parseBotTab(
20+
value: string | null | undefined,
21+
fallback: BotTab = 'settings',
22+
): BotTab {
23+
return isBotTab(value) ? value : fallback
24+
}
25+
26+
export function botPath(name: string, tab: BotTab = 'settings'): string {
27+
return `/bots/${encodeURIComponent(name)}?tab=${tab}`
28+
}

web/src/components/BotSwitcher.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { useEffect, useId, useRef, useState } from 'react'
2+
import { Link, useLocation } from 'react-router-dom'
3+
import { api } from '../api'
4+
import { botPath, parseBotTab, type BotTab } from '../botRoutes'
5+
import type { Bot } from '../types'
6+
import { StatePill } from './ui'
7+
8+
/**
9+
* The detail-page title. When the fleet has another bot, the name is a
10+
* dropdown so you can jump there without going back to Fleet. The current
11+
* `?tab=` is kept so Logs stays Logs.
12+
*/
13+
export default function BotSwitcher({ name }: { name: string }) {
14+
const { search } = useLocation()
15+
const tab: BotTab = parseBotTab(new URLSearchParams(search).get('tab'))
16+
const [bots, setBots] = useState<Bot[] | null>(null)
17+
const [open, setOpen] = useState(false)
18+
const rootRef = useRef<HTMLDivElement>(null)
19+
const triggerRef = useRef<HTMLButtonElement>(null)
20+
const panelId = useId()
21+
22+
useEffect(() => {
23+
let cancelled = false
24+
void api
25+
.fleet()
26+
.then((fleet) => {
27+
if (!cancelled) {
28+
setBots([...fleet.bots].sort((a, b) => a.name.localeCompare(b.name)))
29+
}
30+
})
31+
.catch(() => {
32+
if (!cancelled) setBots([])
33+
})
34+
return () => {
35+
cancelled = true
36+
}
37+
}, [name])
38+
39+
useEffect(() => {
40+
setOpen(false)
41+
}, [name])
42+
43+
useEffect(() => {
44+
if (!open) return
45+
function onPointer(e: MouseEvent | TouchEvent) {
46+
if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
47+
}
48+
function onKey(e: KeyboardEvent) {
49+
if (e.key !== 'Escape') return
50+
setOpen(false)
51+
triggerRef.current?.focus()
52+
}
53+
document.addEventListener('mousedown', onPointer)
54+
document.addEventListener('touchstart', onPointer)
55+
document.addEventListener('keydown', onKey)
56+
return () => {
57+
document.removeEventListener('mousedown', onPointer)
58+
document.removeEventListener('touchstart', onPointer)
59+
document.removeEventListener('keydown', onKey)
60+
}
61+
}, [open])
62+
63+
const others = bots?.filter((b) => b.name !== name) ?? []
64+
// No other bot to open: plain title, no button, no chevron.
65+
// `!bots` is for the type checker — `others.length === 0` already covers null.
66+
if (!bots || others.length === 0) {
67+
return <h1 className="text-xl font-bold">{name}</h1>
68+
}
69+
70+
return (
71+
<div className="relative min-w-0" ref={rootRef}>
72+
<h1 className="min-w-0 text-xl font-bold">
73+
<button
74+
ref={triggerRef}
75+
type="button"
76+
onClick={() => setOpen((v) => !v)}
77+
className={`inline-flex max-w-full items-center gap-1.5 rounded-lg px-2 py-0.5 -mx-2 transition hover:bg-hover ${
78+
open ? 'bg-hover' : ''
79+
}`}
80+
aria-label={`Switch bot, current: ${name}`}
81+
aria-expanded={open}
82+
aria-controls={panelId}
83+
title="Switch bot"
84+
>
85+
<span className="truncate">{name}</span>
86+
<Chevron open={open} />
87+
</button>
88+
</h1>
89+
{open && (
90+
<div
91+
id={panelId}
92+
className="absolute left-0 top-full z-50 mt-1 max-h-80 w-72 max-w-[calc(100vw-2rem)] overflow-auto rounded-xl border border-line-soft bg-surface py-1 shadow-lg"
93+
>
94+
{bots.map((bot) => {
95+
const active = bot.name === name
96+
return (
97+
<Link
98+
key={bot.name}
99+
to={botPath(bot.name, tab)}
100+
onClick={() => setOpen(false)}
101+
className={`flex items-center gap-2 px-3 py-2 text-sm font-normal hover:bg-hover ${
102+
active ? 'bg-accent-tint font-bold text-accent' : 'text-ink'
103+
}`}
104+
aria-current={active ? 'page' : undefined}
105+
>
106+
<span className="min-w-0 flex-1 truncate">{bot.name}</span>
107+
{bot.config?.corridorLabel && (
108+
<span className="max-w-24 truncate text-xs font-normal text-muted">
109+
{bot.config.corridorLabel}
110+
</span>
111+
)}
112+
<StatePill state={bot.state} status={bot.status} />
113+
</Link>
114+
)
115+
})}
116+
</div>
117+
)}
118+
</div>
119+
)
120+
}
121+
122+
function Chevron({ open }: { open: boolean }) {
123+
return (
124+
<svg
125+
width="14"
126+
height="14"
127+
viewBox="0 0 12 12"
128+
fill="none"
129+
aria-hidden
130+
className={`shrink-0 text-muted transition ${open ? 'rotate-180' : ''}`}
131+
>
132+
<path
133+
d="M2.5 4.5 6 8l3.5-3.5"
134+
stroke="currentColor"
135+
strokeWidth="1.5"
136+
strokeLinecap="round"
137+
strokeLinejoin="round"
138+
/>
139+
</svg>
140+
)
141+
}

web/src/pages/AddBot.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useState } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { ApiError, api } from '../api'
4+
import { botPath } from '../botRoutes'
45
import {
56
Banner,
67
Button,
@@ -174,7 +175,7 @@ export default function AddBot({ rfqDefault = false }: { rfqDefault?: boolean })
174175
})
175176
// Clear the secret from component state the moment it's no longer needed.
176177
setSigner(emptySigner)
177-
navigate(`/bots/${encodeURIComponent(res.bot.name)}`, {
178+
navigate(botPath(res.bot.name, res.needsPermit2Approval ? 'tools' : 'settings'), {
178179
state: {
179180
note: res.message,
180181
// From the create API — not `bot.running`. Docker can report running

web/src/pages/BotDetail.tsx

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useCallback, useEffect, useState } from 'react'
2-
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
2+
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
3+
import { BOT_TABS, TAB_LABEL, botPath, parseBotTab, type BotTab } from '../botRoutes'
34
import { ApiError, api } from '../api'
45
import {
56
Banner,
@@ -11,6 +12,7 @@ import {
1112
Tag,
1213
Warnings,
1314
} from '../components/ui'
15+
import BotSwitcher from '../components/BotSwitcher'
1416
import ComposeExportLink from '../components/ComposeExportLink'
1517
import LogViewer from '../components/LogViewer'
1618
import OneShotRunner from '../components/OneShotRunner'
@@ -23,20 +25,10 @@ import { formatTimestamp, shortAddress, shortImage } from '../format'
2325
import { confirmRemovePlan } from '../removeBot'
2426
import type { Bot, ConfigBody, MigrationResult, UpdatesStatus } from '../types'
2527

26-
const TABS = ['settings', 'dashboard', 'config', 'logs', 'tools'] as const
27-
type Tab = (typeof TABS)[number]
28-
29-
const TAB_LABEL: Record<Tab, string> = {
30-
settings: 'Settings',
31-
dashboard: 'Dashboard',
32-
config: 'Raw config',
33-
logs: 'Logs',
34-
tools: 'Tools',
35-
}
36-
3728
export default function BotDetail() {
3829
const { name = '' } = useParams()
3930
const navigate = useNavigate()
31+
const [searchParams, setSearchParams] = useSearchParams()
4032
const [bot, setBot] = useState<Bot | null>(null)
4133
const [error, setError] = useState<string | null>(null)
4234
// The wizard redirects here with what it just did, so its confirmation survives
@@ -54,16 +46,32 @@ export default function BotDetail() {
5446
)
5547
const [busy, setBusy] = useState<string | null>(null)
5648
// After create, land on Tools so Approve allowances is the next obvious step.
57-
const [tab, setTab] = useState<Tab>(() =>
58-
handoff?.needsPermit2 ? 'tools' : 'settings',
59-
)
49+
// Tab lives in `?tab=` so switching bots from the title keeps the same section.
50+
const fallbackTab: BotTab = handoff?.needsPermit2 ? 'tools' : 'settings'
51+
const tab = parseBotTab(searchParams.get('tab'), fallbackTab)
6052
const [updates, setUpdates] = useState<UpdatesStatus | null>(null)
6153

62-
const load = useCallback(async () => {
54+
useEffect(() => {
55+
const raw = searchParams.get('tab')
56+
if (raw === tab) return
57+
setSearchParams(
58+
(prev) => {
59+
const next = new URLSearchParams(prev)
60+
next.set('tab', tab)
61+
return next
62+
},
63+
{ replace: true },
64+
)
65+
}, [searchParams, setSearchParams, tab])
66+
67+
const load = useCallback(async (signal?: { cancelled: boolean }) => {
6368
try {
64-
setBot(await api.bot(name))
69+
const next = await api.bot(name)
70+
if (signal?.cancelled) return
71+
setBot(next)
6572
setError(null)
6673
} catch (e) {
74+
if (signal?.cancelled) return
6775
setError(e instanceof ApiError ? e.message : String(e))
6876
}
6977
}, [name])
@@ -78,8 +86,12 @@ export default function BotDetail() {
7886
}, [])
7987

8088
useEffect(() => {
81-
void load()
89+
const signal = { cancelled: false }
90+
void load(signal)
8291
void loadUpdates()
92+
return () => {
93+
signal.cancelled = true
94+
}
8395
}, [load, loadUpdates])
8496

8597
const botUpdate = updates?.bots.find((b) => b.name === name)
@@ -152,7 +164,7 @@ export default function BotDetail() {
152164
<Link to="/" className="text-sm text-muted hover:text-ink">
153165
← Fleet
154166
</Link>
155-
<h1 className="text-xl font-bold">{bot.name}</h1>
167+
<BotSwitcher name={bot.name} />
156168
<StatePill state={bot.state} status={bot.status} />
157169
{bot.config?.corridorLabel && (
158170
<span className="text-sm text-muted">{bot.config.corridorLabel}</span>
@@ -213,13 +225,13 @@ export default function BotDetail() {
213225
</p>
214226
<p>
215227
Open{' '}
216-
<button
217-
type="button"
228+
<Link
229+
to={botPath(name, 'tools')}
230+
replace
218231
className="font-bold underline hover:no-underline"
219-
onClick={() => setTab('tools')}
220232
>
221233
Tools → Approve allowances
222-
</button>
234+
</Link>
223235
, then dry-run, then Start.
224236
</p>
225237
</div>
@@ -347,19 +359,20 @@ export default function BotDetail() {
347359
className="flex flex-nowrap gap-x-0.5 overflow-x-auto border-b border-line-soft [scrollbar-width:none] [-ms-overflow-style:none] sm:gap-x-1 [&::-webkit-scrollbar]:hidden"
348360
aria-label="Bot sections"
349361
>
350-
{TABS.map((t) => (
351-
<button
362+
{BOT_TABS.map((t) => (
363+
<Link
352364
key={t}
353-
type="button"
354-
onClick={() => setTab(t)}
365+
to={botPath(name, t)}
366+
replace
367+
aria-current={tab === t ? 'page' : undefined}
355368
className={`-mb-px shrink-0 border-b-2 px-2.5 py-2 text-xs font-bold transition sm:px-3 sm:text-sm ${
356369
tab === t
357370
? 'border-accent text-ink'
358371
: 'border-transparent text-muted hover:text-ink'
359372
}`}
360373
>
361374
{TAB_LABEL[t]}
362-
</button>
375+
</Link>
363376
))}
364377
</nav>
365378
<div

0 commit comments

Comments
 (0)