diff --git a/.trellis/tasks/04-19-desktop-log-network-views/prd.md b/.trellis/tasks/04-19-desktop-log-network-views/prd.md new file mode 100644 index 0000000..d66dc16 --- /dev/null +++ b/.trellis/tasks/04-19-desktop-log-network-views/prd.md @@ -0,0 +1,93 @@ +# Desktop Log, Network, And List-First Forms + +## Summary + +Desktop shell already contains provider and alias management pages with inline split-view editing, plus dedicated log and network tabs. Current provider and alias pages always reserve detail-pane space and automatically select first list item. This makes browsing feel heavy and wastes space when user only wants to inspect list. + +This task keeps current data flow and form logic, but changes page behavior to a list-first layout: + +- provider page shows only list by default +- alias route page shows only list by default +- clicking a list item opens edit panel +- clicking new opens create form panel +- import and bind target flows stay modal-based + +## Product Goal + +Make provider and alias route management feel lighter and more focused by separating browsing from editing, while preserving existing CRUD behavior and minimizing architectural churn. + +## Current State + +### Already done + +- `frontend/src/App.tsx` contains provider and alias list, search, filter, create, edit, delete, import, and bind-target flows. +- provider and alias forms already reuse local state and existing API calls. +- desktop shell already uses modal overlays for provider import, alias target binding, and confirm dialogs. + +### Problems + +- provider and alias tabs default to split-view with always-visible detail panel. +- first filtered item is auto-selected, so page never rests in pure list mode. +- empty detail area still occupies layout width before user chooses an action. + +## Requirements + +### Provider page + +- Default state shows only provider list and list controls. +- No provider detail panel should be visible until user clicks a provider row or `New provider`. +- Clicking provider row opens edit panel for that provider. +- Clicking `New provider` opens create panel with empty form. + +### Alias route page + +- Default state shows only alias list and list controls. +- No alias detail panel should be visible until user clicks alias row or `New alias`. +- Clicking alias row opens edit panel for that alias. +- Clicking `New alias` opens create panel with empty form. + +### Interaction and reuse + +- Reuse existing provider and alias form logic as much as possible. +- Reuse current modal/backdrop interaction patterns already used in desktop shell. +- Keep import-provider modal and bind-target modal unchanged unless minor adjustments are required for consistency. +- Keep existing backend/API contracts unchanged. + +## Design Decisions + +### Detail presentation + +Use conditional overlay detail panel instead of always-mounted split-view pane. + +Why: + +- smallest frontend-only change that matches requested behavior +- consistent with existing modal/backdrop interaction already present in app +- preserves current form structure, submit handlers, and confirmation flows + +### Selection behavior + +Do not auto-select first filtered provider or alias. + +Why: + +- page should remain in pure list mode until explicit user action +- prevents accidental context switching when search/filter changes + +## Non-Goals + +1. No backend changes for providers, aliases, logs, or network. +2. No redesign of import-provider or bind-target workflows. +3. No new global state layer or routing changes. +4. No visual redesign outside provider and alias page layout adjustments needed for list-first behavior. + +## Acceptance Criteria + +1. Provider tab initially renders list without visible edit pane. +2. Alias tab initially renders list without visible edit pane. +3. Clicking provider row opens provider edit panel. +4. Clicking alias row opens alias edit panel. +5. Clicking provider create button opens provider create panel. +6. Clicking alias create button opens alias create panel. +7. Search/filter changes do not auto-open a detail panel. +8. Existing save, delete, import, bind, and toggle actions still work. diff --git a/.trellis/tasks/04-19-desktop-log-network-views/task.json b/.trellis/tasks/04-19-desktop-log-network-views/task.json new file mode 100644 index 0000000..a3853b2 --- /dev/null +++ b/.trellis/tasks/04-19-desktop-log-network-views/task.json @@ -0,0 +1,69 @@ +{ + "id": "desktop-log-network-views", + "name": "desktop-log-network-views", + "title": "Design and implement desktop log and network views", + "description": "", + "status": "completed", + "dev_type": "feature", + "scope": "desktop", + "package": null, + "priority": "P2", + "creator": "OpenCode", + "assignee": "OpenCode", + "createdAt": "2026-04-19", + "completedAt": "2026-04-20", + "branch": "master", + "base_branch": "master", + "worktree_path": null, + "current_phase": 6, + "next_action": [ + { + "phase": 1, + "action": "brainstorm" + }, + { + "phase": 2, + "action": "research" + }, + { + "phase": 3, + "action": "implement" + }, + { + "phase": 4, + "action": "check" + }, + { + "phase": 5, + "action": "update-spec" + }, + { + "phase": 6, + "action": "record-session" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + ".trellis/tasks/04-19-desktop-log-network-views/prd.md", + "frontend/src/App.tsx", + "frontend/src/styles.css" + ], + "notes": "Completed desktop provider, alias, log, and network view refresh to use list-first layouts with on-demand detail drawers instead of default split panes. Added keyboard-accessible drawer close flows, automatic first-field focus, shell scroll locking while drawers are open, list-header status/close affordances, and independent log/network detail selection. Fixed a GUI theme regression by preventing routine data refresh after proxy start/stop from overwriting in-memory desktop preferences. Verified with `npm run build` in `frontend`.", + "meta": { + "tests": [ + "npm run build" + ], + "uiPatterns": [ + "list-first detail drawers", + "shared drawer close interactions", + "drawer-only scrolling" + ], + "regressions": [ + "proxy start/stop no longer resets theme presentation" + ] + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 997bfca..21125d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, FormEvent, KeyboardEvent, useCallback, useEffect, useId, useState } from 'react' +import { ChangeEvent, FormEvent, KeyboardEvent, useCallback, useEffect, useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { applySync, @@ -307,6 +307,31 @@ function focusableElements(root: HTMLElement): HTMLElement[] { ) } +function focusFirstInput(root: HTMLElement | null) { + if (!root) { + return + } + const target = root.querySelector( + 'input:not([disabled]):not([type="checkbox"]), select:not([disabled]), textarea:not([disabled])', + ) + ;(target || root).focus() +} + +function focusFirstFocusable(root: HTMLElement | null) { + if (!root) { + return + } + const [firstFocusable] = focusableElements(root) + ;(firstFocusable || root).focus() +} + +function resolveSelectedTraceId(current: number | null, traces: RequestTrace[]): number | null { + if (!current) { + return null + } + return traces.some((trace) => trace.id === current) ? current : null +} + function providerMatches(provider: ProviderView, query: string, filter: FilterState): boolean { if (filter === 'enabled' && provider.disabled) { return false @@ -355,7 +380,8 @@ export default function App() { const [targetForm, setTargetForm] = useState(emptyTargetForm) const [requestTraces, setRequestTraces] = useState([]) const [traceStatus, setTraceStatus] = useState('') - const [selectedTraceId, setSelectedTraceId] = useState(null) + const [selectedLogTraceId, setSelectedLogTraceId] = useState(null) + const [selectedNetworkTraceId, setSelectedNetworkTraceId] = useState(null) const [loading, setLoading] = useState(false) const [activeTab, setActiveTab] = useState('overview') const [providerQuery, setProviderQuery] = useState('') @@ -375,8 +401,13 @@ export default function App() { const [configImportText, setConfigImportText] = useState('') const [configImportFileName, setConfigImportFileName] = useState('') const [confirmIntent, setConfirmIntent] = useState(null) + const providerDetailRef = useRef(null) + const aliasDetailRef = useRef(null) + const logDetailRef = useRef(null) + const networkDetailRef = useRef(null) - const refreshAll = useCallback(async () => { + const refreshAll = useCallback(async (options?: { syncDesktopPrefs?: boolean }) => { + const syncDesktopPrefs = options?.syncDesktopPrefs ?? false setLoading(true) setPrefsStatus(i18n.t('messages.refreshing')) try { @@ -391,11 +422,14 @@ export default function App() { setOverview(overviewData) setProviders(providerData) setAliases(aliasData) - setPrefs(overviewData.desktop) + if (syncDesktopPrefs) { + setPrefs(overviewData.desktop) + } setProxySettings(proxySettingsData) const traces = await listRequestTraces() setRequestTraces(traces) - setSelectedTraceId((current) => current ?? traces[0]?.id ?? null) + setSelectedLogTraceId((current) => resolveSelectedTraceId(current, traces)) + setSelectedNetworkTraceId((current) => resolveSelectedTraceId(current, traces)) setPrefsStatus(i18n.t('messages.fresh')) setProxySettingsStatus(i18n.t('messages.fresh')) setTraceStatus(i18n.t('messages.fresh')) @@ -409,7 +443,7 @@ export default function App() { }, []) useEffect(() => { - void refreshAll() + void refreshAll({ syncDesktopPrefs: true }) }, [refreshAll]) useEffect(() => { @@ -452,7 +486,12 @@ export default function App() { const filteredAliases = aliases.filter((alias) => aliasMatches(alias, aliasSearch)) const selectedProvider = providers.find((provider) => provider.id === selectedProviderId) || null const selectedAlias = aliases.find((alias) => alias.alias === selectedAliasId) || null - const selectedTrace = requestTraces.find((trace) => trace.id === selectedTraceId) || requestTraces[0] || null + const providerDetailOpen = providerDetailMode !== 'empty' + const aliasDetailOpen = aliasDetailMode !== 'empty' + const selectedLogTrace = requestTraces.find((trace) => trace.id === selectedLogTraceId) || null + const selectedNetworkTrace = requestTraces.find((trace) => trace.id === selectedNetworkTraceId) || null + const logDetailOpen = selectedLogTraceId !== null && selectedLogTrace !== null + const networkDetailOpen = selectedNetworkTraceId !== null && selectedNetworkTrace !== null const proxyRunning = overview?.proxy.running ?? false const stats = overview ? [ @@ -496,12 +535,8 @@ export default function App() { return } setRequestTraces(traces) - setSelectedTraceId((current) => { - if (current && traces.some((trace) => trace.id === current)) { - return current - } - return traces[0]?.id ?? null - }) + setSelectedLogTraceId((current) => resolveSelectedTraceId(current, traces)) + setSelectedNetworkTraceId((current) => resolveSelectedTraceId(current, traces)) setTraceStatus(i18n.t('messages.fresh')) } catch (error) { if (!cancelled) { @@ -519,48 +554,76 @@ export default function App() { } }, [activeTab]) + useEffect(() => { + if (activeTab === 'providers' && providerDetailOpen) { + focusFirstInput(providerDetailRef.current) + } + }, [activeTab, providerDetailOpen]) + + useEffect(() => { + if (activeTab === 'aliases' && aliasDetailOpen) { + focusFirstInput(aliasDetailRef.current) + } + }, [activeTab, aliasDetailOpen]) + + useEffect(() => { + if (activeTab === 'log' && logDetailOpen) { + focusFirstFocusable(logDetailRef.current) + } + }, [activeTab, logDetailOpen]) + + useEffect(() => { + if (activeTab === 'network' && networkDetailOpen) { + focusFirstFocusable(networkDetailRef.current) + } + }, [activeTab, networkDetailOpen]) + + useEffect(() => { + const shouldLockScroll = providerDetailOpen || aliasDetailOpen || logDetailOpen || networkDetailOpen + const appShell = document.querySelector('.app-shell') + const sidebar = document.querySelector('.sidebar') + const workspace = document.querySelector('.workspace') + if (!shouldLockScroll) { + return + } + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + appShell?.classList.add('shell-locked') + sidebar?.classList.add('shell-locked') + workspace?.classList.add('shell-locked') + return () => { + document.body.style.overflow = previousOverflow + appShell?.classList.remove('shell-locked') + sidebar?.classList.remove('shell-locked') + workspace?.classList.remove('shell-locked') + } + }, [aliasDetailOpen, logDetailOpen, networkDetailOpen, providerDetailOpen]) + useEffect(() => { if (providerDetailMode === 'create') { return } - if (selectedProviderId && filteredProviders.some((provider) => provider.id === selectedProviderId)) { - return - } - if (filteredProviders.length > 0) { - const next = filteredProviders[0] - setSelectedProviderId(next.id) - setEditingProviderId(next.id) - setProviderForm(providerFormFromView(next)) - setProviderDetailMode('edit') + if (selectedProviderId && providers.some((provider) => provider.id === selectedProviderId)) { return } setSelectedProviderId(null) setEditingProviderId('') setProviderForm(emptyProviderForm) setProviderDetailMode('empty') - }, [filteredProviders, providerDetailMode, selectedProviderId]) + }, [providerDetailMode, providers, selectedProviderId]) useEffect(() => { if (aliasDetailMode === 'create') { return } - if (selectedAliasId && filteredAliases.some((alias) => alias.alias === selectedAliasId)) { - return - } - if (filteredAliases.length > 0) { - const next = filteredAliases[0] - setSelectedAliasId(next.alias) - setEditingAliasId(next.alias) - setAliasForm(aliasFormFromView(next)) - setTargetForm((current) => ({ ...current, alias: next.alias })) - setAliasDetailMode('edit') + if (selectedAliasId && aliases.some((alias) => alias.alias === selectedAliasId)) { return } setSelectedAliasId(null) setEditingAliasId('') setAliasForm(emptyAliasForm) setAliasDetailMode('empty') - }, [aliasDetailMode, filteredAliases, selectedAliasId]) + }, [aliasDetailMode, aliases, selectedAliasId]) function selectTab(tab: TabKey) { window.location.hash = tab @@ -599,6 +662,26 @@ export default function App() { setConfirmIntent(null) } + function closeProviderDetail() { + setSelectedProviderId(null) + resetProviderForm() + setProviderDetailMode('empty') + } + + function closeAliasDetail() { + setSelectedAliasId(null) + resetAliasForm() + setAliasDetailMode('empty') + } + + function closeLogDetail() { + setSelectedLogTraceId(null) + } + + function closeNetworkDetail() { + setSelectedNetworkTraceId(null) + } + function openProviderCreateModal() { resetProviderForm() setSelectedProviderId(null) @@ -658,6 +741,34 @@ export default function App() { } } + function onProviderDetailKeyDown(event: KeyboardEvent) { + trapModalFocus(event) + if (event.key === 'Escape') { + closeProviderDetail() + } + } + + function onAliasDetailKeyDown(event: KeyboardEvent) { + trapModalFocus(event) + if (event.key === 'Escape') { + closeAliasDetail() + } + } + + function onLogDetailKeyDown(event: KeyboardEvent) { + trapModalFocus(event) + if (event.key === 'Escape') { + closeLogDetail() + } + } + + function onNetworkDetailKeyDown(event: KeyboardEvent) { + trapModalFocus(event) + if (event.key === 'Escape') { + closeNetworkDetail() + } + } + async function onSavePrefs(event: FormEvent) { event.preventDefault() setPrefsStatus(i18n.t('messages.saving')) @@ -1009,6 +1120,10 @@ export default function App() { const importModeLabelId = useId() const providerImportTitleId = useId() const aliasTargetTitleId = useId() + const providerDetailTitleId = useId() + const aliasDetailTitleId = useId() + const logDetailTitleId = useId() + const networkDetailTitleId = useId() const confirmTitleId = useId() return ( @@ -1152,18 +1267,31 @@ export default function App() { ) : null} {activeTab === 'providers' ? ( -
+

{t('providers.title')}

-

{t('providers.subtitle')}

+

+ {providerDetailOpen + ? providerDetailMode === 'create' + ? t('providers.formCreateTitle') + : selectedProviderId + ? t('providers.formEditTitle', { id: selectedProviderId }) + : t('providers.subtitle') + : t('providers.subtitle')} +

-
+
{providerStatus || t('providers.listCount', { shown: filteredProviders.length, total: providers.length })} + {providerDetailOpen ? ( + + ) : null} @@ -1222,7 +1350,7 @@ export default function App() {
- -
-
-
-

- {providerDetailMode === 'create' - ? t('providers.formCreateTitle') - : editingProviderId - ? t('providers.formEditTitle', { id: editingProviderId }) - : t('providers.listTitle')} -

-

- {providerDetailMode === 'empty' - ? t('providers.selectHint') - : providerStatus || t('providers.detailHint')} -

-
- {providerDetailMode !== 'empty' ? ( -
- {providerDetailMode === 'edit' && selectedProvider ? ( - - ) : null} - {providerDetailMode === 'edit' && selectedProvider ? ( - - ) : null} -
- ) : null} -
- - {providerDetailMode === 'empty' ? ( -
-

{t('providers.selectTitle')}

-

{t('providers.selectHint')}

-
- ) : ( -
void onSaveProvider(event)}> -
-
- {t('providers.baseUrl')} - {selectedProvider?.baseUrl || providerForm.baseUrl || '-'} -
-
- {t('providers.models')} - {selectedProvider?.models?.join(', ') || t('providers.modelsNone')} -
-
- - - - -