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
19 changes: 19 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Enforce LF line endings for all text files so Windows/macOS/Linux
# contributors see a clean `git status` without line-ending noise.
* text=auto eol=lf

# Keep Windows-native files as CRLF if any are ever added.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

# Binary files that should never be touched.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
- Keep provider event logging opt-in. Raw provider prompts, tool payloads, approval answers, and runtime output must not be persisted by default; use `CUT3_ENABLE_PROVIDER_EVENT_LOGS=1` only for deliberate local debugging.
- Keep provider exit failures visible end-to-end. If a runtime emits `session.exited` with a non-graceful reason, orchestration must preserve that reason in `thread.session.lastError` so OpenCode/Copilot/Kimi/Codex crashes do not look like silent clean stops.
- When testing hot orchestration streams backed by PubSub, avoid `fork + sleep` subscription races. Start the collector with an explicit readiness handshake (for example `Effect.forkScoped` plus `Effect.yieldNow`, or another deterministic subscription barrier) before dispatching commands.
- Keep interactive controls properly disabled during in-flight async operations (e.g. export, share, revoke): users must not be able to trigger conflicting actions while a prior action is still completing. Guard format toggles, download buttons, and secondary actions behind the relevant `isSaving`/`isRevoking` flags.
- Keep ARIA semantics aligned with visual affordances: disclosure/expand buttons need `aria-expanded`, toggle-style buttons need `aria-pressed` or `role="radio"` with `aria-checked`, icon-only buttons need explicit `aria-label`, tree-like file lists need `role="tree"`, and controls revealed only on hover (e.g. terminal close buttons) must also be revealed on `focus-visible` so keyboard users can reach them.
- Keep `aria-label` values on interactive groups and their trigger buttons accurate and descriptive of the actual feature. Do not leave placeholder labels from copy-paste (e.g. "Subscription actions" for an editor picker, "Copy options" for an editor menu).
- When a button visually looks disabled (opacity, cursor-not-allowed), make it actually `disabled` so it is removed from tab order and does not fire click handlers. CSS-only faux-disabled states are a keyboard trap.

## Project Snapshot

Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/DiffWorkerPoolProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@
import { useTheme } from "../hooks/useTheme";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";

/**
* Catch the fire-and-forget `initialize()` promise on the worker pool singleton.
*
* `@pierre/diffs` calls `workerPoolManager.initialize()` inside
* `getOrCreateWorkerPoolSingleton` without attaching a `.catch()`. When the
* React tree unmounts quickly (e.g. browser-test teardown), the pool is
* terminated while init is still in flight, which causes an unhandled
* rejection ("WorkerPoolManager: workers failed to initialize"). Attaching a
* no-op catch here prevents that from surfacing as a test/runtime error.
*/
function DiffWorkerPoolInitGuard() {
const workerPool = useWorkerPool();

useEffect(() => {
if (!workerPool) return;
// `initialized` is `false | Promise<void> | true` on WorkerPoolManager.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accessing internal library field

Check warning on line 23 in apps/web/src/components/DiffWorkerPoolProvider.tsx

View workflow job for this annotation

GitHub Actions / Linux quality gates

oxlint

Unused eslint-disable directive (no problems were reported).
const pending = (workerPool as unknown as Record<string, unknown>).initialized;
if (pending && typeof (pending as Promise<void>).catch === "function") {
(pending as Promise<void>).catch(() => undefined);
}
}, [workerPool]);

return null;
}

function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
const workerPool = useWorkerPool();

Expand Down Expand Up @@ -49,6 +75,7 @@
tokenizeMaxLineLength: 1_000,
}}
>
<DiffWorkerPoolInitGuard />
<DiffWorkerThemeSync themeName={diffThemeName} />
{children}
</WorkerPoolContextProvider>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/PlanSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ const PlanSidebar = memo(function PlanSidebar({
<button
type="button"
className="group flex w-full items-center gap-1.5 text-left"
aria-expanded={proposedPlanExpanded}
onClick={() => setProposedPlanExpanded((v) => !v)}
>
{proposedPlanExpanded ? (
Expand Down
34 changes: 25 additions & 9 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,15 +457,30 @@ interface TerminalActionButtonProps {
label: string;
className: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}

function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
function TerminalActionButton({
label,
className,
onClick,
disabled,
children,
}: TerminalActionButtonProps) {
return (
<Popover>
<PopoverTrigger
openOnHover
render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
render={
<button
type="button"
className={className}
onClick={onClick}
disabled={disabled}
aria-label={label}
/>
}
>
{children}
</PopoverTrigger>
Expand Down Expand Up @@ -729,6 +744,9 @@ export default function ThreadTerminalDrawer({
style={{ height: `${drawerHeight}px` }}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal"
className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
onPointerDown={handleResizePointerDown}
onPointerMove={handleResizePointerMove}
Expand All @@ -741,12 +759,11 @@ export default function ThreadTerminalDrawer({
<div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
<TerminalActionButton
className={`p-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-45 hover:bg-transparent"
: "hover:bg-accent"
hasReachedSplitLimit ? "opacity-45" : "hover:bg-accent"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
disabled={hasReachedSplitLimit}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
Expand Down Expand Up @@ -832,12 +849,11 @@ export default function ThreadTerminalDrawer({
<div className="inline-flex h-full items-stretch">
<TerminalActionButton
className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-45 hover:bg-transparent"
: "hover:bg-accent/70"
hasReachedSplitLimit ? "opacity-45" : "hover:bg-accent/70"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
disabled={hasReachedSplitLimit}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
Expand Down Expand Up @@ -921,7 +937,7 @@ export default function ThreadTerminalDrawer({
render={
<button
type="button"
className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1 focus-visible:ring-ring"
onClick={() => onCloseTerminal(terminalId)}
aria-label={closeTerminalLabel}
/>
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/chat/ChangedFilesTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
type="button"
className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
style={{ paddingLeft: `${leftPadding}px` }}
aria-expanded={isExpanded}
onClick={() => toggleDirectory(node.path, depth === 0)}
>
<ChevronRightIcon
Expand Down Expand Up @@ -111,7 +112,15 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
);
};

return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;
return (
<div role="tree" aria-label="Changed files" className="space-y-0.5">
{treeNodes.length === 0 ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground/60">No changed files.</p>
) : (
treeNodes.map((node) => renderTreeNode(node, 0))
)}
</div>
);
});

function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ text }: { tex
size="xs"
variant="outline"
onClick={() => copyToClipboard(text)}
aria-label={title}
title={title}
>
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const OpenInPicker = memo(function OpenInPicker({
}, [preferredEditor, keybindings, openInCwd]);

return (
<Group aria-label="Subscription actions">
<Group aria-label="Open in editor">
<Button
size="xs"
variant="outline"
Expand All @@ -119,7 +119,9 @@ export const OpenInPicker = memo(function OpenInPicker({
</Button>
<GroupSeparator className="hidden @sm/header-actions:block" />
<Menu>
<MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>
<MenuTrigger
render={<Button aria-label="More editor options" size="icon-xs" variant="outline" />}
>
<ChevronDownIcon aria-hidden="true" className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ProposedPlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
size="sm"
variant="outline"
data-scroll-anchor-ignore
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? "Collapse plan" : "Expand plan"}
Expand Down
27 changes: 22 additions & 5 deletions apps/web/src/components/chat/ThreadExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,21 @@ export const ThreadExportDialog = memo(function ThreadExportDialog(props: {
</DialogHeader>
<DialogPanel className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">Format</p>
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-foreground" id="export-format-label">
Format
</p>
<div
className="flex items-center gap-2"
role="radiogroup"
aria-labelledby="export-format-label"
>
<button
type="button"
role="radio"
aria-checked={props.format === "markdown"}
disabled={props.isSavingToWorkspace}
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
"rounded-full border px-3 py-1.5 text-sm transition-colors disabled:pointer-events-none disabled:opacity-50",
props.format === "markdown"
? "border-border bg-accent text-accent-foreground"
: "border-border/70 text-muted-foreground hover:text-foreground",
Expand All @@ -58,8 +67,11 @@ export const ThreadExportDialog = memo(function ThreadExportDialog(props: {
</button>
<button
type="button"
role="radio"
aria-checked={props.format === "json"}
disabled={props.isSavingToWorkspace}
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
"rounded-full border px-3 py-1.5 text-sm transition-colors disabled:pointer-events-none disabled:opacity-50",
props.format === "json"
? "border-border bg-accent text-accent-foreground"
: "border-border/70 text-muted-foreground hover:text-foreground",
Expand Down Expand Up @@ -88,7 +100,12 @@ export const ThreadExportDialog = memo(function ThreadExportDialog(props: {
</label>
</DialogPanel>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" size="sm" onClick={props.onDownload}>
<Button
variant="outline"
size="sm"
onClick={props.onDownload}
disabled={props.isSavingToWorkspace}
>
Download {props.format === "json" ? "JSON" : "Markdown"}
</Button>
<div className="flex items-center gap-2">
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/chat/ThreadShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ export const ThreadShareDialog = memo(function ThreadShareDialog(props: {
variant="outline"
size="sm"
onClick={props.onOpenSharedView}
disabled={!hasShare || !props.shareUrl}
disabled={!hasShare || !props.shareUrl || props.isRevokingShare}
>
Open shared view
</Button>
<Button size="sm" onClick={props.onCopyLink} disabled={!hasShare || !props.shareUrl}>
<Button
size="sm"
onClick={props.onCopyLink}
disabled={!hasShare || !props.shareUrl || props.isRevokingShare}
>
Copy link
</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export function PermissionPoliciesSection({
size="icon-sm"
variant="ghost"
disabled={isFirst}
aria-label="Move rule up"
onClick={() => onChangeRules(moveRule(rules, rule.id, -1))}
>
<ArrowUpIcon className="size-4" />
Expand All @@ -192,13 +193,15 @@ export function PermissionPoliciesSection({
size="icon-sm"
variant="ghost"
disabled={isLast}
aria-label="Move rule down"
onClick={() => onChangeRules(moveRule(rules, rule.id, 1))}
>
<ArrowDownIcon className="size-4" />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label="Delete rule"
onClick={() => onChangeRules(rules.filter((entry) => entry.id !== rule.id))}
>
<Trash2Icon className="size-4" />
Expand Down Expand Up @@ -302,6 +305,7 @@ export function PermissionPoliciesSection({
key={option.value}
size="xs"
variant={selected ? "default" : "outline"}
aria-pressed={selected}
onClick={() =>
onChangeRules(
updateRuleAt(rules, rule.id, (current) =>
Expand Down