Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions web/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,45 @@ modal covers the screen anyway, but this can be improved in a future pass
file, which triggers the `react-refresh/only-export-components` lint warning.
This is consistent with the pre-existing `useProjects.tsx` pattern.

## Mobile Knowledge Base

On viewports narrower than `768px` (Tailwind `md` breakpoint) the Knowledge
Base tab replaces the always-visible sidebar with a slide-in sheet triggered by
the doc-title row. Desktop layout is unchanged.

### Architecture

| File | Role |
|---|---|
| `web/src/components/KnowledgeBase/MobileDocSheet.tsx` | Backdrop overlay + right-side slide-in panel (reuses `card-panel` and `animate-panel-slide-in` CSS classes). Renders `KnowledgeBaseSidebar` inside. Accepts all sidebar props plus `onClose: () => void`. Calls `onClose` after a doc is selected (intercepts `onSelect`). |
| `web/src/components/KnowledgeBase/KnowledgeBase.tsx` | Owns `isSheetOpen: boolean` state. Applies `hidden md:flex` to the sidebar wrapper. Renders `<MobileDocSheet>` when `isSheetOpen` is true. Passes `onOpenSelector={() => setIsSheetOpen(true)}` to `KnowledgeDocViewer`. |
| `web/src/components/KnowledgeBase/KnowledgeDocViewer.tsx` | Accepts `onOpenSelector?: () => void`. Renders a `md:hidden` trigger button at the top of the component showing the current doc name + chevron. Clicking it calls `onOpenSelector?.()`. Also forwards `onOpenSelector` to `KnowledgeDocEditor` when editing. |
| `web/src/components/KnowledgeBase/KnowledgeDocEditor.tsx` | Accepts `onOpenSelector?: () => void`. Renders the same `md:hidden` trigger button at the top. When no doc is provided, shows "Choose a document ›" as the prompt. |

### Behaviour

- **Desktop (≥ `md`):** Two-column flex layout. Sidebar always visible on the
left (`w-72`). Trigger row hidden (`md:hidden`).
- **Mobile (< `md`):** Viewer/editor takes full width. Sidebar hidden
(`hidden md:flex`). Trigger row visible at the top showing current doc name +
chevron, or "Choose a document ›" when no doc is selected. Tapping the trigger
sets `isSheetOpen = true`.

### Closing the sheet

The sheet closes on either of:
- Tap the dark backdrop (`onClick` on the `bg-black/50` overlay)
- Select any document (intercepted by `MobileDocSheet.handleSelect`)

### CSS conventions

`MobileDocSheet` uses `card-panel` and `animate-panel-slide-in` — the same
classes used by `CardPanel` — so z-index and animation stay consistent with
the rest of the app. No new CSS was added.

The trigger button uses `style` props (CSS custom properties) rather than
Tailwind color classes, consistent with the project-wide convention.

## Subtask parent navigation

Subtask cards display their parent card ID as a clickable badge. Clicking it
Expand Down
15 changes: 14 additions & 1 deletion web/src/components/KnowledgeBase/KnowledgeBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { api, errorMessage } from '../../api/client';
import { KnowledgeBaseSidebar } from './KnowledgeBaseSidebar';
import { KnowledgeDocViewer } from './KnowledgeDocViewer';
import { MobileDocSheet } from './MobileDocSheet';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useKnowledgeBaseData } from './useKnowledgeBaseData';
import { useKnowledgeRefreshStatus } from './useKnowledgeRefreshStatus';
Expand Down Expand Up @@ -47,6 +48,7 @@ export function KnowledgeBase({ project }: { project: string }) {

const refreshStatus = useKnowledgeRefreshStatus(project);
const [planModal, setPlanModal] = useState<{ repo: string; plan: RefreshPlan } | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);

const reload = useCallback(async () => {
if (selected) {
Expand Down Expand Up @@ -149,7 +151,7 @@ export function KnowledgeBase({ project }: { project: string }) {

return (
<div className="flex h-full">
<div className="flex flex-col h-full min-h-0">
<div className="hidden md:flex flex-col h-full min-h-0">
{summaryError && (
<div
className="px-3 py-2 text-xs"
Expand Down Expand Up @@ -189,6 +191,7 @@ export function KnowledgeBase({ project }: { project: string }) {
onSaved={reload}
onDirtyChange={setDirty}
onRefreshClick={handleRefreshClick}
onOpenSelector={() => setIsSheetOpen(true)}
refreshing={
refreshStatus.repos[selected.repo]?.state === 'planning' ||
refreshStatus.repos[selected.repo]?.state === 'running'
Expand Down Expand Up @@ -216,6 +219,16 @@ export function KnowledgeBase({ project }: { project: string }) {
)}
</div>
{modal}
{isSheetOpen && (
<MobileDocSheet
summary={summary}
selected={selected}
onSelect={guard}
onRefreshClick={handleRefreshClick}
refreshStatusByRepo={refreshStatus.repos}
onClose={() => setIsSheetOpen(false)}
/>
)}
{planModal && (
<RefreshPlanModal
plan={planModal.plan}
Expand Down
28 changes: 27 additions & 1 deletion web/src/components/KnowledgeBase/KnowledgeDocEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ const MDEditor = lazy(() => import('@uiw/react-md-editor'));

interface EditorProps {
initialContent: string;
doc?: string;
onCancel: () => void;
onSave: (content: string, signal: AbortSignal) => Promise<void>;
onDirtyChange?: (dirty: boolean) => void;
onOpenSelector?: () => void;
}

const MIN_EDITOR_PX = 160;

export function KnowledgeDocEditor({ initialContent, onCancel, onSave, onDirtyChange }: EditorProps) {
export function KnowledgeDocEditor({ initialContent, doc, onCancel, onSave, onDirtyChange, onOpenSelector }: EditorProps) {
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
Expand Down Expand Up @@ -77,6 +79,30 @@ export function KnowledgeDocEditor({ initialContent, onCancel, onSave, onDirtyCh

return (
<section className="flex flex-col h-full" data-color-mode={theme}>
{onOpenSelector && (
<button
type="button"
onClick={onOpenSelector}
aria-label="Open document selector"
className="md:hidden px-4 py-2 flex items-center gap-2 text-sm w-full text-left"
style={{ borderBottom: '1px solid var(--bg3)', color: 'var(--fg)', backgroundColor: 'var(--bg0)' }}
>
{doc ? (
<>
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--grey1)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span className="flex-1 truncate" aria-hidden="true">{doc}</span>
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--grey1)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
</>
) : (
<span style={{ color: 'var(--grey1)' }}>Choose a document ›</span>
)}
</button>
)}
<header
className="flex items-center justify-between gap-3 px-6 py-3"
style={{
Expand Down
22 changes: 22 additions & 0 deletions web/src/components/KnowledgeBase/KnowledgeDocViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface ViewerProps {
onDirtyChange?: (dirty: boolean) => void;
refreshing?: boolean;
onRefreshClick?: (repo: string) => void;
onOpenSelector?: () => void;
}

const docTitleGlyph = (
Expand Down Expand Up @@ -63,6 +64,7 @@ export function KnowledgeDocViewer({
onDirtyChange,
refreshing,
onRefreshClick,
onOpenSelector,
}: ViewerProps) {
const [editing, setEditing] = useState(false);
const { theme } = useTheme();
Expand All @@ -71,6 +73,7 @@ export function KnowledgeDocViewer({
return (
<KnowledgeDocEditor
initialContent={response.content}
doc={doc}
onCancel={() => {
onDirtyChange?.(false);
setEditing(false);
Expand All @@ -84,6 +87,7 @@ export function KnowledgeDocViewer({
setEditing(false);
}}
onDirtyChange={onDirtyChange}
onOpenSelector={onOpenSelector}
/>
);
}
Expand All @@ -98,6 +102,24 @@ export function KnowledgeDocViewer({

return (
<div className="flex flex-col h-full">
{onOpenSelector && (
<button
type="button"
onClick={onOpenSelector}
aria-label="Open document selector"
className="md:hidden px-4 py-2 flex items-center gap-2 text-sm w-full text-left"
style={{ borderBottom: '1px solid var(--bg3)', color: 'var(--fg)', backgroundColor: 'var(--bg0)' }}
>
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--grey1)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span className="flex-1 truncate" aria-hidden="true">{doc}</span>
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--grey1)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
<header
className="flex flex-wrap items-start gap-x-4 gap-y-3 px-7 py-5"
style={{ borderBottom: '1px solid var(--bg3)', backgroundColor: 'var(--bg0)' }}
Expand Down
46 changes: 46 additions & 0 deletions web/src/components/KnowledgeBase/MobileDocSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { KnowledgeBaseSummary, RefreshJobStatus } from '../../types';
import { KnowledgeBaseSidebar } from './KnowledgeBaseSidebar';

interface MobileDocSheetProps {
summary: KnowledgeBaseSummary;
selected: { repo: string; doc: string } | null;
onSelect: (sel: { repo: string; doc: string }) => void;
onRefreshClick?: (repo: string) => void;
refreshStatusByRepo?: Record<string, RefreshJobStatus>;
onClose: () => void;
}

export function MobileDocSheet({
summary,
selected,
onSelect,
onRefreshClick,
refreshStatusByRepo,
onClose,
}: MobileDocSheetProps) {
const handleSelect = (sel: { repo: string; doc: string }) => {
onSelect(sel);
onClose();
};

return (
<>
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} aria-hidden="true" />
<div
className="card-panel animate-panel-slide-in"
style={{ width: '18rem', minWidth: 0, maxWidth: '100vw' }}
role="dialog"
aria-modal="true"
aria-label="Select a document"
>
<KnowledgeBaseSidebar
summary={summary}
selected={selected}
onSelect={handleSelect}
onRefreshClick={onRefreshClick}
refreshStatusByRepo={refreshStatusByRepo}
/>
</div>
</>
);
}
Loading