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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ TESTING.md
FEATURE_SUMMARY.md
CODE_CHANGES.md
README_COMPACT_HERE.md
artifacts/
tests/e2e/tmp/
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
.PHONY: all build dev start clean help
.PHONY: build-main build-preload build-renderer
.PHONY: lint lint-fix fmt fmt-check fmt-shell fmt-shell-check typecheck static-check
.PHONY: test test-unit test-integration test-watch test-coverage
.PHONY: test test-unit test-integration test-watch test-coverage test-e2e
.PHONY: dist dist-mac dist-win dist-linux
.PHONY: docs docs-build docs-watch

Expand Down Expand Up @@ -110,6 +110,9 @@ test-watch: ## Run tests in watch mode
test-coverage: ## Run tests with coverage
@./scripts/test.sh --coverage

test-e2e: ## Run end-to-end tests
@PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 bun x playwright test --project=electron

## Distribution
dist: build ## Build distributable packages
@bun x electron-builder --publish never
Expand Down
274 changes: 112 additions & 162 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[test]
root = "src"
match = [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx"
]
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test:watch": "make test-watch",
"test:coverage": "make test-coverage",
"test:integration": "make test-integration",
"test:e2e": "make test-e2e",
"dist": "make dist",
"dist:mac": "make dist-mac",
"dist:win": "make dist-win",
Expand Down Expand Up @@ -59,6 +60,7 @@
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@playwright/test": "^1.56.0",
"@testing-library/react": "^16.3.0",
"@types/bun": "^1.2.23",
"@types/diff": "^8.0.0",
Expand All @@ -81,6 +83,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^30.1.3",
"playwright": "^1.56.0",
"prettier": "^3.6.2",
"ts-jest": "^29.4.4",
"tsc-alias": "^1.8.16",
Expand Down
34 changes: 34 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineConfig } from "@playwright/test";

const isCI = process.env.CI === "true";

export default defineConfig({
testDir: "./tests/e2e",
timeout: 120_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
forbidOnly: isCI,
retries: isCI ? 1 : 0,
reporter: [
["list"],
["html", { outputFolder: "artifacts/playwright-report", open: "never" }],
],
workers: 1,
use: {
trace: isCI ? "on-first-retry" : "retain-on-failure",
screenshot: "only-on-failure",
video: {
mode: "on",
size: { width: 1280, height: 720 },
},
},
outputDir: "artifacts/playwright-output",
projects: [
{
name: "electron",
testDir: "./tests/e2e",
},
],
});
9 changes: 7 additions & 2 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const EditBarrier = styled.div`
text-align: center;
`;

const JumpToBottomIndicator = styled.div`
const JumpToBottomIndicator = styled.button`
position: absolute;
bottom: 8px;
left: 50%;
Expand Down Expand Up @@ -391,6 +391,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
onWheel={markUserInteraction}
onTouchMove={markUserInteraction}
onScroll={handleScroll}
role="log"
aria-live={canInterrupt ? "polite" : "off"}
aria-busy={canInterrupt}
aria-label="Conversation transcript"
tabIndex={0}
>
{messages.length === 0 ? (
<EmptyState>
Expand Down Expand Up @@ -444,7 +449,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
)}
</OutputContent>
{!autoScroll && (
<JumpToBottomIndicator onClick={jumpToBottom}>
<JumpToBottomIndicator onClick={jumpToBottom} type="button">
Press {formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)} to jump to bottom
</JumpToBottomIndicator>
)}
Expand Down
11 changes: 10 additions & 1 deletion src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import React, { useState, useRef, useCallback, useEffect, useId } from "react";
import styled from "@emotion/styled";
import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "./CommandSuggestions";
import type { Toast } from "./ChatInputToast";
Expand Down Expand Up @@ -300,6 +300,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const modelSelectorRef = useRef<ModelSelectorRef>(null);
const [mode, setMode] = useMode();
const { recentModels } = useModelLRU();
const commandListId = useId();

// Get current send message options from shared hook (must be at component top level)
const sendMessageOptions = useSendMessageOptions(workspaceId);
Expand Down Expand Up @@ -731,6 +732,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
onSelectSuggestion={handleCommandSelect}
onDismiss={() => setShowCommandSuggestions(false)}
isVisible={showCommandSuggestions}
ariaLabel="Slash command suggestions"
listId={commandListId}
/>
<InputControls data-component="ChatInputControls">
<VimTextArea
Expand All @@ -743,6 +746,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
placeholder={placeholder}
disabled={disabled || isSending || isCompacting}
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
aria-autocomplete="list"
aria-controls={
showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined
}
aria-expanded={showCommandSuggestions && commandSuggestions.length > 0}
/>
</InputControls>
<ModeToggles data-component="ChatModeToggles">
Expand Down
13 changes: 10 additions & 3 deletions src/components/ChatInputToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export const ChatInputToast: React.FC<ChatInputToastProps> = ({ toast, onDismiss
if (isRichError) {
return (
<ToastWrapper>
<ErrorContainer>
<ErrorContainer role="alert" aria-live="assertive">
<div style={{ display: "flex", alignItems: "flex-start", gap: "6px" }}>
<ToastIcon>⚠</ToastIcon>
<div style={{ flex: 1 }}>
Expand All @@ -212,7 +212,9 @@ export const ChatInputToast: React.FC<ChatInputToastProps> = ({ toast, onDismiss
<ErrorDetails>{toast.message}</ErrorDetails>
{toast.solution && <ErrorSolution>{toast.solution}</ErrorSolution>}
</div>
<CloseButton onClick={handleDismiss}>×</CloseButton>
<CloseButton onClick={handleDismiss} aria-label="Dismiss">
×
</CloseButton>
</div>
</ErrorContainer>
</ToastWrapper>
Expand All @@ -222,7 +224,12 @@ export const ChatInputToast: React.FC<ChatInputToastProps> = ({ toast, onDismiss
// Regular toast for simple messages and success
return (
<ToastWrapper>
<ToastContainer type={toast.type} isLeaving={isLeaving}>
<ToastContainer
type={toast.type}
isLeaving={isLeaving}
role={toast.type === "error" ? "alert" : "status"}
aria-live={toast.type === "error" ? "assertive" : "polite"}
>
<ToastIcon>{toast.type === "success" ? "✓" : "⚠"}</ToastIcon>
<ToastContent>
{toast.title && <ToastTitle>{toast.title}</ToastTitle>}
Expand Down
42 changes: 36 additions & 6 deletions src/components/ChatMetaSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,49 @@ export const ChatMetaSidebar: React.FC<ChatMetaSidebarProps> = ({ workspaceId })
"costs"
);

const baseId = `chat-meta-${workspaceId}`;
const costsTabId = `${baseId}-tab-costs`;
const toolsTabId = `${baseId}-tab-tools`;
const costsPanelId = `${baseId}-panel-costs`;
const toolsPanelId = `${baseId}-panel-tools`;

return (
<SidebarContainer>
<TabBar>
<TabButton active={selectedTab === "costs"} onClick={() => setSelectedTab("costs")}>
<SidebarContainer role="complementary" aria-label="Workspace insights">
<TabBar role="tablist" aria-label="Metadata views">
<TabButton
active={selectedTab === "costs"}
onClick={() => setSelectedTab("costs")}
id={costsTabId}
role="tab"
type="button"
aria-selected={selectedTab === "costs"}
aria-controls={costsPanelId}
>
Costs
</TabButton>
<TabButton active={selectedTab === "tools"} onClick={() => setSelectedTab("tools")}>
<TabButton
active={selectedTab === "tools"}
onClick={() => setSelectedTab("tools")}
id={toolsTabId}
role="tab"
type="button"
aria-selected={selectedTab === "tools"}
aria-controls={toolsPanelId}
>
Tools
</TabButton>
</TabBar>
<TabContent>
{selectedTab === "costs" && <CostsTab />}
{selectedTab === "tools" && <ToolsTab />}
{selectedTab === "costs" && (
<div role="tabpanel" id={costsPanelId} aria-labelledby={costsTabId}>
<CostsTab />
</div>
)}
{selectedTab === "tools" && (
<div role="tabpanel" id={toolsPanelId} aria-labelledby={toolsTabId}>
<ToolsTab />
</div>
)}
</TabContent>
</SidebarContainer>
);
Expand Down
20 changes: 19 additions & 1 deletion src/components/CommandSuggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface CommandSuggestionsProps {
onSelectSuggestion: (suggestion: SlashSuggestion) => void;
onDismiss: () => void;
isVisible: boolean;
ariaLabel?: string;
listId?: string;
}

// Styled components
Expand Down Expand Up @@ -83,6 +85,8 @@ export const CommandSuggestions: React.FC<CommandSuggestionsProps> = ({
onSelectSuggestion,
onDismiss,
isVisible,
ariaLabel = "Command suggestions",
listId,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);

Expand Down Expand Up @@ -141,14 +145,28 @@ export const CommandSuggestions: React.FC<CommandSuggestionsProps> = ({
return null;
}

const activeSuggestion = suggestions[selectedIndex] ?? suggestions[0];
const resolvedListId = listId ?? `command-suggestions-list`;

return (
<PopoverContainer data-command-suggestions>
<PopoverContainer
id={resolvedListId}
role="listbox"
aria-label={ariaLabel}
aria-activedescendant={
activeSuggestion ? `${resolvedListId}-option-${activeSuggestion.id}` : undefined
}
data-command-suggestions
>
{suggestions.map((suggestion, index) => (
<CommandItem
key={suggestion.id}
selected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => onSelectSuggestion(suggestion)}
id={`${resolvedListId}-option-${suggestion.id}`}
role="option"
aria-selected={index === selectedIndex}
>
<CommandText>{suggestion.display}</CommandText>
<CommandDescription>{suggestion.description}</CommandDescription>
Expand Down
12 changes: 6 additions & 6 deletions src/components/GitStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ const IndicatorChar = styled.span<{ branch: number }>`
case 0:
return "#6bcc6b"; // Green for HEAD
case 1:
return "#b66bcc"; // Purple for origin/<branch>
case 2:
return "#6ba3cc"; // Blue for origin/main
case 2:
return "#b66bcc"; // Purple for origin/branch
default:
return "#6b6b6b"; // Gray fallback
}
Expand Down Expand Up @@ -312,11 +312,11 @@ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
PRIMARY_BRANCH=$(git branch -r 2>/dev/null | grep -E 'origin/(main|master)$' | head -1 | sed 's@^.*origin/@@' || echo "main")

# Build refs list for show-branch
# Order: HEAD, origin/<current-branch> (if exists and different), origin/<primary-branch>
REFS="HEAD origin/$PRIMARY_BRANCH"

# Check if origin/<current-branch> exists and is different from primary
if [ "$CURRENT_BRANCH" != "$PRIMARY_BRANCH" ] && git rev-parse --verify "origin/$CURRENT_BRANCH" >/dev/null 2>&1; then
REFS="HEAD origin/$CURRENT_BRANCH origin/$PRIMARY_BRANCH"
else
REFS="HEAD origin/$PRIMARY_BRANCH"
REFS="$REFS origin/$CURRENT_BRANCH"
fi

# Store show-branch output to avoid running twice
Expand Down
24 changes: 19 additions & 5 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from "react";
import React, { useEffect, useCallback, useId } from "react";
import styled from "@emotion/styled";
import { matchesKeybind, KEYBINDS } from "@/utils/ui/keybinds";

Expand Down Expand Up @@ -114,6 +114,7 @@ interface ModalProps {
maxWidth?: string;
maxHeight?: string;
isLoading?: boolean;
describedById?: string;
}

export const Modal: React.FC<ModalProps> = ({
Expand All @@ -125,7 +126,12 @@ export const Modal: React.FC<ModalProps> = ({
maxWidth,
maxHeight,
isLoading = false,
describedById,
}) => {
const headingId = useId();
const subtitleId = subtitle ? `${headingId}-subtitle` : undefined;
const ariaDescribedBy = [subtitleId, describedById].filter(Boolean).join(" ") || undefined;

const handleCancel = useCallback(() => {
if (!isLoading) {
onClose();
Expand All @@ -149,10 +155,18 @@ export const Modal: React.FC<ModalProps> = ({
if (!isOpen) return null;

return (
<ModalOverlay>
<ModalContent maxWidth={maxWidth} maxHeight={maxHeight}>
<h2>{title}</h2>
{subtitle && <ModalSubtitle>{subtitle}</ModalSubtitle>}
<ModalOverlay role="presentation" onClick={handleCancel}>
<ModalContent
maxWidth={maxWidth}
maxHeight={maxHeight}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
aria-describedby={ariaDescribedBy}
onClick={(event) => event.stopPropagation()}
>
<h2 id={headingId}>{title}</h2>
{subtitle && <ModalSubtitle id={subtitleId}>{subtitle}</ModalSubtitle>}
{children}
</ModalContent>
</ModalOverlay>
Expand Down
Loading