diff --git a/frontend/playwright/ui-smoke.spec.ts b/frontend/playwright/ui-smoke.spec.ts index ffb2657..ba27050 100644 --- a/frontend/playwright/ui-smoke.spec.ts +++ b/frontend/playwright/ui-smoke.spec.ts @@ -51,7 +51,7 @@ test('renders main wiki shell instead of a white screen', async ({ page }) => { await expect(page.getByRole('heading', { name: 'GolemCore Brain' })).toBeVisible() await expect(page.getByRole('banner').getByRole('button', { name: 'Search' })).toBeVisible() await expect(page.getByTestId('sidebar')).toBeVisible() - await expect(page.getByRole('button', { name: 'Tree' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Tree' })).toBeVisible() const bodyText = await page.locator('body').innerText() expect(bodyText.trim().length).toBeGreaterThan(20) diff --git a/frontend/src/components/ModalCard.tsx b/frontend/src/components/ModalCard.tsx index 6d6b831..a86fe39 100644 --- a/frontend/src/components/ModalCard.tsx +++ b/frontend/src/components/ModalCard.tsx @@ -17,6 +17,7 @@ */ import * as Dialog from '@radix-ui/react-dialog' +import clsx from 'clsx' import { X } from 'lucide-react' import type { PropsWithChildren, ReactNode } from 'react' @@ -26,6 +27,8 @@ interface ModalCardProps extends PropsWithChildren { description?: string onOpenChange: (open: boolean) => void footer?: ReactNode + /** Widens the card for side-by-side content such as a conflict diff. */ + wide?: boolean } export function ModalCard({ @@ -35,12 +38,18 @@ export function ModalCard({ onOpenChange, children, footer, + wide = false, }: ModalCardProps) { return ( - +
@@ -52,7 +61,7 @@ export function ModalCard({ ) : null}
- +
diff --git a/frontend/src/components/TreeNodeItem.tsx b/frontend/src/components/TreeNodeItem.tsx index cd3da29..7fa7194 100644 --- a/frontend/src/components/TreeNodeItem.tsx +++ b/frontend/src/components/TreeNodeItem.tsx @@ -16,9 +16,9 @@ * Contact: alex@kuleshov.tech */ -import { ChevronDown, ChevronRight, Copy, FileText, Folder, FolderOpen, List, MoreVertical, Move, Pencil, Plus, Repeat2, Trash2 } from 'lucide-react' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { ChevronDown, ChevronRight, Copy, FileText, Folder, FolderOpen, FolderPlus, List, MoreVertical, Move, Pencil, Plus, Repeat2, Trash2 } from 'lucide-react' import clsx from 'clsx' -import { useState } from 'react' import type { MouseEvent } from 'react' import type { WikiNodeKind, WikiTreeNode } from '../types' @@ -58,7 +58,6 @@ export function TreeNodeItem({ onSort, onConvert, }: TreeNodeItemProps) { - const [mobileActionsOpen, setMobileActionsOpen] = useState(false) const isActive = node.path === activePath const isOpen = node.kind !== 'PAGE' && openPaths.includes(node.path) const hasChildren = node.children.length > 0 @@ -68,6 +67,8 @@ export function TreeNodeItem({ : node.kind === 'SECTION' && !hasChildren ? 'PAGE' : null + const canCreateHere = canCreate && node.kind !== 'PAGE' + const hasActions = canEdit || canCreateHere const handleNavigate = (event: MouseEvent) => { event.stopPropagation() @@ -82,7 +83,7 @@ export function TreeNodeItem({
  • {node.kind === 'PAGE' ? ( - + ) : ( )} - {canEdit || (canCreate && node.kind !== 'PAGE') ? ( - <> -
    - {canEdit ? ( - <> - - - - {node.kind !== 'PAGE' ? ( - - ) : null} - {convertTargetKind ? ( - - ) : null} - - - ) : null} - {canCreate && node.kind !== 'PAGE' ? ( - <> - - - - ) : null} -
    -
    + {hasActions ? ( + + - {mobileActionsOpen ? ( -
    - {canEdit ? ( - <> - - - - {node.kind !== 'PAGE' ? ( - - ) : null} - {convertTargetKind ? ( - - ) : null} - - - ) : null} - {canCreate && node.kind !== 'PAGE' ? ( - <> - - - - ) : null} -
    - ) : null} -
    - +
    {node.kind !== 'PAGE' && hasChildren && isOpen ? ( diff --git a/frontend/src/features/assets/AssetManagerDialog.tsx b/frontend/src/features/assets/AssetManagerDialog.tsx index cb5953f..5d09cf1 100644 --- a/frontend/src/features/assets/AssetManagerDialog.tsx +++ b/frontend/src/features/assets/AssetManagerDialog.tsx @@ -270,9 +270,14 @@ export function AssetManagerDialog({ No assets uploaded yet. ) : null} + {/* + Rows wrap rather than overflow: on a phone the dialog is far narrower than the intrinsic + width of the name column plus the action cluster. Rename gets its own full width line so + the field is never squeezed to a fixed 160px inside a ~260px dialog. + */} {assets.map((asset) => ( -
    -
    +
    +
    {asset.name}
    {asset.contentType} · {Math.round(asset.size / 1024)} KB
    @@ -287,32 +292,24 @@ export function AssetManagerDialog({
    -
    +
    {renderInsertButtons(asset)} - {renameTarget === asset.name ? ( -
    - setRenameValue(event.target.value)} - /> - -
    - ) : ( - - )} +
    + {renameTarget === asset.name ? ( +
    + setRenameValue(event.target.value)} + aria-label={`New name for ${asset.name}`} + /> + + +
    + ) : null}
    ))}
    diff --git a/frontend/src/features/chat/SpaceChatPage.test.tsx b/frontend/src/features/chat/SpaceChatPage.test.tsx index 5671359..ef17786 100644 --- a/frontend/src/features/chat/SpaceChatPage.test.tsx +++ b/frontend/src/features/chat/SpaceChatPage.test.tsx @@ -134,6 +134,26 @@ describe('SpaceChatPage', () => { }) }) + it('sends on Enter and keeps Shift+Enter for a new line', async () => { + render( + + + , + ) + + await screen.findByLabelText('Chat model') + const question = screen.getByLabelText('Question') + + fireEvent.change(question, { target: { value: 'Line one' } }) + fireEvent.keyDown(question, { key: 'Enter', shiftKey: true }) + expect(chatWithSpaceMock).not.toHaveBeenCalled() + + fireEvent.keyDown(question, { key: 'Enter' }) + await waitFor(() => { + expect(chatWithSpaceMock).toHaveBeenCalledWith('Line one', [], 'chat-model', null, 1) + }) + }) + it('lets users choose among enabled chat models', async () => { render( diff --git a/frontend/src/features/chat/SpaceChatPage.tsx b/frontend/src/features/chat/SpaceChatPage.tsx index db18c36..fb8c95d 100644 --- a/frontend/src/features/chat/SpaceChatPage.tsx +++ b/frontend/src/features/chat/SpaceChatPage.tsx @@ -105,7 +105,12 @@ export function SpaceChatPage() {
    -
    +
    {messages.length === 0 ? (
    Ask a question about this space
    @@ -167,9 +172,19 @@ export function SpaceChatPage() { className="field-input min-h-24 resize-y" value={draft} placeholder="Ask a question about this space…" + aria-describedby="chat-send-hint" onChange={(event) => setDraft(event.target.value)} + // Enter sends, Shift+Enter adds a newline — the convention every chat UI uses. + onKeyDown={(event) => { + if (event.key !== 'Enter' || event.shiftKey || !canSend) { + return + } + event.preventDefault() + event.currentTarget.form?.requestSubmit() + }} /> +

    Press Enter to send, Shift and Enter for a new line.

    -
    +
    diff --git a/frontend/src/features/editor/PageEditor.test.tsx b/frontend/src/features/editor/PageEditor.test.tsx index 005e568..11fe477 100644 --- a/frontend/src/features/editor/PageEditor.test.tsx +++ b/frontend/src/features/editor/PageEditor.test.tsx @@ -157,7 +157,7 @@ describe('PageEditor', () => { ) fireEvent.click(screen.getByRole('button', { name: 'Edit metadata' })) - expect(screen.getByText('Metadata')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Metadata' })).toBeInTheDocument() expect(screen.getByText('Unsaved changes')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled() diff --git a/frontend/src/features/editor/PageEditor.tsx b/frontend/src/features/editor/PageEditor.tsx index 322c8bc..48726bc 100644 --- a/frontend/src/features/editor/PageEditor.tsx +++ b/frontend/src/features/editor/PageEditor.tsx @@ -25,6 +25,7 @@ import { toast } from 'sonner' import { uploadAsset } from '../../lib/api' import { editorPathToRoute, normalizeWikiPath, pathToRoute } from '../../lib/paths' import { InsertWikiLinkDialog } from '../../components/InsertWikiLinkDialog' +import { ModalCard } from '../../components/ModalCard' import { AssetManagerDialog } from '../assets/AssetManagerDialog' import { buildDefaultMarkdownForAsset } from '../assets/assetMarkdown' import { MarkdownPreview } from '../preview/MarkdownPreview' @@ -177,6 +178,11 @@ export function PageEditor() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + // A blocking prompt owns the keyboard; re-firing save/close from behind it would act on + // state the author is still being asked about. + if (showUnsavedDialog || showConflictDialog) { + return + } const modifier = event.metaKey || event.ctrlKey if (modifier && event.key.toLowerCase() === 's') { event.preventDefault() @@ -354,20 +360,30 @@ export function PageEditor() { /{page.path}
    + {/* + The fixed mobile bar has roughly 100px per column, which is not enough for the full + labels. Each button keeps its full accessible name and shows a short label on phones + so nothing wraps onto a second line. + */}
    @@ -492,60 +510,78 @@ export function PageEditor() { onSelect={handleInsertWikiLink} /> - {showUnsavedDialog ? ( -
    -
    -

    Unsaved changes

    -

    - You have unsaved changes. Do you want to discard them and continue? -

    -
    - - -
    -
    -
    - ) : null} - - {showConflictDialog && conflict ? ( -
    -
    -

    Page changed in another session

    -

    - The latest saved version was updated at {formatTimestamp(conflict.updatedAt)}. Reload it to discard your draft, or rebase your draft onto the latest revision for a manual merge. -

    -
    -
    -
    Your draft
    -
    /{(page.parentPath ? `${page.parentPath}/` : '') + slug}
    -
    {title}
    -
    {content || '(empty)'}
    -
    -
    -
    Latest saved version
    -
    /{conflict.path}
    -
    {conflict.title}
    -
    {conflict.content || '(empty)'}
    -
    + {/* + Both prompts guard destructive outcomes, so they run through ModalCard (Radix Dialog) to + get a focus trap, Escape-to-dismiss and dialog semantics instead of a bare overlay div. + */} + { + if (!open) { + handleCancelNavigation() + } + }} + footer={( + <> + + + + )} + > +

    + Discarding leaves the last saved version of this page untouched. +

    +
    + + { + if (!open) { + setShowConflictDialog(false) + } + }} + footer={( + <> + + + + + )} + > + {conflict ? ( +
    +
    +
    Your draft
    +
    /{(page.parentPath ? `${page.parentPath}/` : '') + slug}
    +
    {title}
    +
    {content || '(empty)'}
    -
    - - - +
    +
    Latest saved version
    +
    /{conflict.path}
    +
    {conflict.title}
    +
    {conflict.content || '(empty)'}
    -
    - ) : null} + ) : null} +
    ) } diff --git a/frontend/src/features/layout/AppLayout.tsx b/frontend/src/features/layout/AppLayout.tsx index bc39266..d867510 100644 --- a/frontend/src/features/layout/AppLayout.tsx +++ b/frontend/src/features/layout/AppLayout.tsx @@ -47,7 +47,6 @@ interface AppLayoutProps { onConvert: (path: string, targetKind: Exclude) => void onExpandAll: () => void onCollapseAll: () => void - onOpenSearch: () => void currentUsername?: string | null canManageUsers: boolean canAccessAccount: boolean @@ -80,7 +79,6 @@ export function AppLayout({ onConvert, onExpandAll, onCollapseAll, - onOpenSearch, currentUsername, canManageUsers, canAccessAccount, @@ -111,6 +109,8 @@ export function AppLayout({ className="app-layout__sidebar-toggle-button" onClick={onToggleSidebar} aria-label="Toggle Sidebar" + aria-expanded={sidebarVisible} + aria-controls="sidebar-container" > @@ -173,7 +173,6 @@ export function AppLayout({ onConvert={onConvert} onExpandAll={onExpandAll} onCollapseAll={onCollapseAll} - onOpenSearch={onOpenSearch} imageVersion={displayImageVersion} />
    diff --git a/frontend/src/features/links/LinkInfo.test.tsx b/frontend/src/features/links/LinkInfo.test.tsx index 1408ae7..478b1f0 100644 --- a/frontend/src/features/links/LinkInfo.test.tsx +++ b/frontend/src/features/links/LinkInfo.test.tsx @@ -190,4 +190,85 @@ describe('LinkInfo', () => { expect(screen.getByRole('link', { name: 'Runbook' })).toHaveAttribute('href', '/brain/guides/runbook') expect(screen.getByRole('link', { name: 'Checklist' })).toHaveAttribute('href', '/brain/shared/checklist') }) + + it('lists a repeatedly referenced page only once', () => { + useViewerStore.setState({ + linkStatus: { + backlinks: [ + { + fromPageId: 'guides/runbook', + fromPath: 'guides/runbook', + fromTitle: 'Runbook', + toPageId: 'guides/setup', + toPath: 'guides/setup', + toTitle: 'Setup', + broken: false, + }, + { + fromPageId: 'guides/runbook', + fromPath: 'guides/runbook', + fromTitle: 'Runbook', + toPageId: 'guides/setup', + toPath: 'guides/setup', + toTitle: 'Setup', + broken: false, + }, + ], + brokenIncoming: [], + outgoings: [ + { + fromPageId: 'guides/setup', + fromPath: 'guides/setup', + fromTitle: 'Setup', + toPageId: 'shared/checklist', + toPath: 'shared/checklist', + toTitle: 'Checklist', + broken: false, + }, + { + fromPageId: 'guides/setup', + fromPath: 'guides/setup', + fromTitle: 'Setup', + toPageId: 'shared/checklist', + toPath: 'shared/checklist', + toTitle: 'Checklist', + broken: false, + }, + ], + brokenOutgoings: [ + { + fromPageId: 'guides/setup', + fromPath: 'guides/setup', + fromTitle: 'Setup', + toPageId: null, + toPath: 'shared/missing', + toTitle: 'Missing', + broken: true, + }, + { + fromPageId: 'guides/setup', + fromPath: 'guides/setup', + fromTitle: 'Setup', + toPageId: null, + toPath: 'shared/missing', + toTitle: 'Missing', + broken: true, + }, + ], + }, + history: [], + }) + + render( + + + , + ) + + expect(screen.getAllByRole('link', { name: 'Runbook' })).toHaveLength(1) + expect(screen.getAllByRole('link', { name: 'Checklist' })).toHaveLength(1) + expect(screen.getAllByText('Missing')).toHaveLength(1) + expect(screen.getByText('Backlinks').parentElement).toHaveTextContent('1') + expect(screen.getByText('Outgoing links').parentElement).toHaveTextContent('2') + }) }) diff --git a/frontend/src/features/links/LinkInfo.tsx b/frontend/src/features/links/LinkInfo.tsx index 1499bc1..a1e5d2c 100644 --- a/frontend/src/features/links/LinkInfo.tsx +++ b/frontend/src/features/links/LinkInfo.tsx @@ -27,7 +27,7 @@ import { pathToRoute } from '../../lib/paths' import { useTreeStore } from '../../stores/tree' import { useUiStore } from '../../stores/ui' import { useViewerStore } from '../../stores/viewer' -import type { WikiPageHistoryVersion } from '../../types' +import type { WikiLinkStatusItem, WikiPageHistoryVersion } from '../../types' import { MarkdownPreview } from '../preview/MarkdownPreview' import { buildLineDiff } from './historyDiff' @@ -38,6 +38,25 @@ function formatTimestamp(timestamp?: string) { return new Date(timestamp).toLocaleString() } +/** + * Collapses link rows that point at the same page. A body may reference the same target several + * times, and every reference resolves to an identical row; listing it once keeps the pane readable + * and keeps React keys unique. + */ +function dedupeLinks(items: WikiLinkStatusItem[], keyOf: (item: WikiLinkStatusItem) => string) { + const seen = new Set() + const unique: Array<{ key: string; item: WikiLinkStatusItem }> = [] + for (const item of items) { + const key = keyOf(item) + if (seen.has(key)) { + continue + } + seen.add(key) + unique.push({ key, item }) + } + return unique +} + export function LinkInfo() { const page = useViewerStore((state) => state.page) const linkStatus = useViewerStore((state) => state.linkStatus) @@ -120,6 +139,19 @@ export function LinkInfo() { removed: diffLines.filter((line) => line.type === 'removed').length, }), [diffLines]) + const backlinks = useMemo( + () => dedupeLinks(linkStatus?.backlinks ?? [], (item) => item.fromPath || item.fromPageId || item.fromTitle || ''), + [linkStatus], + ) + const outgoings = useMemo( + () => dedupeLinks(linkStatus?.outgoings ?? [], (item) => item.toPath || item.toPageId || item.toTitle || ''), + [linkStatus], + ) + const brokenOutgoings = useMemo( + () => dedupeLinks(linkStatus?.brokenOutgoings ?? [], (item) => item.toPath || item.toTitle || ''), + [linkStatus], + ) + if (!linkStatus) { return null } @@ -130,15 +162,15 @@ export function LinkInfo() {
    Backlinks - {linkStatus.backlinks.length} + {backlinks.length}
    - {linkStatus.backlinks.length === 0 ? ( + {backlinks.length === 0 ? (

    No pages reference this page.

    ) : (
      - {linkStatus.backlinks.map((item) => ( -
    • + {backlinks.map(({ key, item }) => ( +
    • {item.fromPath ? ( {item.fromTitle ?? item.fromPath} ) : ( @@ -153,15 +185,15 @@ export function LinkInfo() {
      Outgoing links - {linkStatus.outgoings.length + linkStatus.brokenOutgoings.length} + {outgoings.length + brokenOutgoings.length}
      - {linkStatus.outgoings.length === 0 && linkStatus.brokenOutgoings.length === 0 ? ( + {outgoings.length === 0 && brokenOutgoings.length === 0 ? (

      No outgoing links on this page.

      ) : (
        - {linkStatus.outgoings.map((item) => ( -
      • + {outgoings.map(({ key, item }) => ( +
      • {item.toPath ? ( {item.toTitle ?? item.toPath} @@ -170,8 +202,8 @@ export function LinkInfo() { )}
      • ))} - {linkStatus.brokenOutgoings.map((item) => ( -
      • + {brokenOutgoings.map(({ key, item }) => ( +
      • {item.toTitle}
      • diff --git a/frontend/src/features/preview/MarkdownPreview.tsx b/frontend/src/features/preview/MarkdownPreview.tsx index b90a9f4..1242b00 100644 --- a/frontend/src/features/preview/MarkdownPreview.tsx +++ b/frontend/src/features/preview/MarkdownPreview.tsx @@ -88,6 +88,15 @@ export function MarkdownPreview({ content, path, darkMode, assetVersion }: Markd video: (props: React.VideoHTMLAttributes) => (