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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ of the product.
the behavior implied by its name; do not move unrelated actions into it to hide
coordination. An optional feature must be removable by deleting its import and
composition node without breaking sibling capabilities.
- Express UI variants with focused components and early returns. Do not accumulate
JSX in mutable variables or turn one component into a dispatcher for unrelated UI.
- Mutable JSX variables are an antipattern. Compose focused components with early
returns instead of assigning JSX to `let` variables.
- Keep one source of truth and derive the rest. Model state with one explicit status,
not overlapping booleans or synchronization effects.
- Keep logic above JSX. Avoid ternaries and boolean chains in markup. Use
Expand Down
14 changes: 10 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,17 @@ lib/ lowest-level reusable modules and extractable feature packages
chat-voice-input/
self-contained voice input package
components/
app-header.tsx
shared route header and optional workspace/preview actions
app-sidebar.tsx
responsive application navigation and session management
session-list-item.tsx
session navigation row, rename, and row actions
ui/ generic visual primitives
composer/ message input, optional controls, and submit composition
code/ Pierre-backed source and diff facades
session/ conversation, activity, navigation, and preview control
workspace/ file navigation, tree, queries, and panel
chat/ reusable chat layout, actions, scrolling, and composition
code/ lazy Pierre-backed diff facade
session/ session lifecycle, messages, activity, and preview control
workspace/ file navigation, tree, queries, source viewer, and panel
app/ routes, pages, providers, and top-level wiring
tests/ pure contract and runtime tests
vercel.json Vite web service plus Eve service
Expand Down
4 changes: 2 additions & 2 deletions app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { href, Outlet, useMatch, useNavigate } from "react-router";

import { SessionSidebar } from "@/components/session/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import { useComposerStore } from "@/lib/composer-store";

function useMediaQuery(query: string): boolean {
Expand Down Expand Up @@ -44,7 +44,7 @@ export function App() {

return (
<div className="flex h-dvh overflow-hidden bg-background text-foreground">
<SessionSidebar
<AppSidebar
isCollapsed={isSidebarCollapsed && !isMobile}
isOpen={isSidebarOpen}
onClose={() => setSidebarOpen(false)}
Expand Down
4 changes: 2 additions & 2 deletions app/home-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useConvexMutation } from "@convex-dev/react-query";
import { href, useNavigate } from "react-router";

import { PageHeader } from "@/components/session/page-header";
import { AppHeader } from "@/components/app-header";
import { SessionStart } from "@/components/session/session-start";
import { api } from "@/convex/_generated/api";
import type { GitRepository } from "@/lib/github";
Expand Down Expand Up @@ -37,7 +37,7 @@ export function HomePage() {

return (
<main className="flex min-w-0 flex-1 flex-col bg-background">
<PageHeader title="New session" />
<AppHeader title="New session" />
<SessionStart onImport={importRepository} onStart={startSession} />
</main>
);
Expand Down
1 change: 0 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
{
"includes": [
"components/code/diff-renderer.tsx",
"components/code/file-viewer.tsx",
"components/workspace/use-workspace-tree.ts",
"components/workspace/workspace-browser.tsx",
"components/workspace/workspace-panel.tsx",
Expand Down
182 changes: 182 additions & 0 deletions components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Download, GitFork, PanelLeft, PanelRight, SquarePen, X } from "lucide-react";
import { useOutletContext } from "react-router";

import { PreviewControl } from "@/components/session/preview-control";
import { Button } from "@/components/ui/button";
import { MenuItem } from "@/components/ui/menu";
import type { Preview } from "@/lib/preview";
import { getWorkspaceUrl } from "@/lib/workspace";

type AppHeaderProps = {
readonly branch?: string;
readonly isWorkspaceOpen?: boolean;
readonly onToggleWorkspace?: () => void;
readonly preview?: Preview;
readonly repository?: string;
readonly title: string;
readonly workspaceSessionId?: string;
};

type SidebarContext = {
readonly isSidebarExpanded: boolean;
readonly openNewSession: () => void;
readonly toggleSidebar: () => void;
};

function WorkspaceToggleIcon({ isOpen }: { readonly isOpen: boolean }) {
if (!isOpen) return <PanelRight aria-hidden="true" />;

return (
<>
<X aria-hidden="true" className="md:hidden" />
<PanelRight aria-hidden="true" className="hidden md:block" />
</>
);
}

function WorkspaceToggle({
isOpen,
onToggle,
}: {
readonly isOpen?: boolean;
readonly onToggle?: () => void;
}) {
if (!onToggle) return null;
const label = isOpen ? "Close files" : "Open files";

return (
<Button
aria-controls="workspace-panel"
aria-expanded={Boolean(isOpen)}
aria-label={label}
onClick={onToggle}
size="icon"
variant="ghost"
>
<WorkspaceToggleIcon isOpen={Boolean(isOpen)} />
</Button>
);
}

function CollapsedSidebarActions({
isExpanded,
onNewSession,
onToggle,
}: {
readonly isExpanded: boolean;
readonly onNewSession: () => void;
readonly onToggle: () => void;
}) {
if (isExpanded) return null;

return (
<>
<Button
aria-controls="session-sidebar"
aria-expanded="false"
aria-label="Expand sessions"
onClick={onToggle}
size="icon"
variant="ghost"
>
<PanelLeft aria-hidden="true" />
</Button>
<Button aria-label="New session" onClick={onNewSession} size="icon" variant="ghost">
<SquarePen aria-hidden="true" />
</Button>
<span aria-hidden="true" className="mx-1 h-5 w-px bg-border" />
</>
);
}

function BranchLabel({ branch }: { readonly branch?: string }) {
if (!branch) return null;
return <span className="shrink-0 font-mono text-xs">{branch}</span>;
}

function GitLabel({
branch,
repository,
}: {
readonly branch?: string;
readonly repository?: string;
}) {
if (!repository && !branch) return null;

return (
<span className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
<GitFork aria-hidden="true" className="size-4 shrink-0" />
<span className="truncate">{repository ?? "Git repository"}</span>
<BranchLabel branch={branch} />
</span>
);
}

function WorkspaceDownloadAction({
menuId,
sessionId,
}: {
readonly menuId: string;
readonly sessionId?: string;
}) {
function onDownload(): void {
if (!sessionId) return;
window.location.assign(`${getWorkspaceUrl(sessionId)}/download`);
}

return (
<MenuItem className="text-sm" disabled={!sessionId} onClick={onDownload} popoverTarget={menuId}>
<Download aria-hidden="true" className="size-3.5" />
Download
</MenuItem>
);
}

function HeaderPreview({
preview,
sessionId,
}: {
readonly preview?: Preview;
readonly sessionId?: string;
}) {
if (!preview) return null;
const menuId = `sandbox-info-${preview.sandboxId}`;

return (
<PreviewControl
actions={<WorkspaceDownloadAction menuId={menuId} sessionId={sessionId} />}
menuId={menuId}
preview={preview}
/>
);
}

export function AppHeader({
branch,
isWorkspaceOpen,
onToggleWorkspace,
preview,
repository,
title,
workspaceSessionId,
}: AppHeaderProps) {
const { isSidebarExpanded, openNewSession, toggleSidebar } = useOutletContext<SidebarContext>();

return (
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3 sm:px-4">
<CollapsedSidebarActions
isExpanded={isSidebarExpanded}
onNewSession={openNewSession}
onToggle={toggleSidebar}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<h1 className="truncate font-medium">{title}</h1>
<GitLabel branch={branch} repository={repository} />
</div>
<div className="flex shrink-0 items-center gap-2">
<HeaderPreview preview={preview} sessionId={workspaceSessionId} />
<WorkspaceToggle isOpen={isWorkspaceOpen} onToggle={onToggleWorkspace} />
</div>
</header>
);
}
74 changes: 67 additions & 7 deletions components/session/sidebar.tsx → components/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,75 @@
import { useConvexPaginatedQuery } from "@convex-dev/react-query";
import { useConvexMutation, useConvexPaginatedQuery } from "@convex-dev/react-query";
import { useMutation } from "@tanstack/react-query";
import { AlignLeft, PanelLeft, SquarePen, X } from "lucide-react";
import { useState } from "react";
import { href, useNavigate } from "react-router";

import { DeleteSessionDialog } from "@/components/session/delete-dialog";
import { SessionSidebarItem, type SessionSummary } from "@/components/session/sidebar-item";
import { SessionListItem, type SessionSummary } from "@/components/session-list-item";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { api } from "@/convex/_generated/api";
import { clearSessionRuntime } from "@/lib/session-runtime";
import { cn } from "@/lib/utils";

type SessionSidebarProps = {
type DeleteSessionDialogProps = {
readonly onClose: () => void;
readonly onDeleted: (sessionId: string) => void;
readonly target?: SessionSummary;
};

function DeleteSessionDialog({ onClose, onDeleted, target }: DeleteSessionDialogProps) {
const removeSession = useMutation({ mutationFn: useConvexMutation(api.sessions.remove) });

function remove(): void {
if (!target) return;
removeSession.mutate(
{ sessionId: target.sessionId },
{
onSuccess: () => {
clearSessionRuntime(target.sessionId);
onDeleted(target.sessionId);
document.querySelector<HTMLDialogElement>("#delete-session-dialog")?.close();
},
},
);
}

return (
<dialog
aria-describedby="delete-session-description"
aria-labelledby="delete-session-title"
className="m-auto w-[min(28rem,calc(100%-2rem))] rounded-xl border bg-card p-5 text-card-foreground shadow-2xl backdrop:bg-black/70"
id="delete-session-dialog"
onCancel={(event) => removeSession.isPending && event.preventDefault()}
onClose={() => {
removeSession.reset();
onClose();
}}
>
<h2 className="font-medium" id="delete-session-title">
Delete session permanently?
</h2>
<p className="mt-2 text-muted-foreground" id="delete-session-description">
“{target?.name}” and its history cannot be recovered.
</p>
{removeSession.isError && (
<Alert className="mt-4" variant="destructive">
Could not delete this session.
</Alert>
)}
<form className="mt-5 flex justify-end gap-2" method="dialog">
<Button disabled={removeSession.isPending} type="submit" variant="outline">
Cancel
</Button>
<Button disabled={removeSession.isPending} onClick={remove} variant="destructive">
Delete
</Button>
</form>
</dialog>
);
}

type AppSidebarProps = {
readonly isCollapsed: boolean;
readonly isOpen: boolean;
readonly onClose: () => void;
Expand All @@ -18,14 +78,14 @@ type SessionSidebarProps = {
readonly selectedSessionId: string | null;
};

export function SessionSidebar({
export function AppSidebar({
isCollapsed,
isOpen,
onClose,
onNewSession,
onToggle,
selectedSessionId,
}: SessionSidebarProps) {
}: AppSidebarProps) {
const {
loadMore,
results: sessions,
Expand Down Expand Up @@ -106,7 +166,7 @@ export function SessionSidebar({
>
<p className="px-2 pb-1.5 text-sm font-medium text-muted-foreground">Sessions</p>
{sessions.map((session) => (
<SessionSidebarItem
<SessionListItem
isSelected={selectedSessionId === session.sessionId}
key={session.sessionId}
onDelete={openDelete}
Expand Down
Loading