Add ESLint rule to ban optional props in container components (Vibe Kanban) (#2115)

* Done. The ESLint rule is now in place, banning optional props in all ui-new components.

**Summary:**
- Added the rule to `frontend/.eslintrc.cjs` targeting `TSPropertySignature[optional=true]`
- The rule applies to all files under `src/components/ui-new/**/*.{ts,tsx}`

**Impact Assessment - 100 violations across:**

| Category | Files | Violations |
|----------|-------|------------|
| **primitives/** | 11 files | ~40 violations |
| **views/** | 5 files | ~15 violations |
| **containers/** | 5 files | ~10 violations |
| **dialogs/** | 7 files | ~15 violations |
| **Root-level** | 5 files | ~15 violations |
| **types/** | 1 file | 5 violations |

The most affected files are:
- `primitives/Dropdown.tsx` (14 violations)
- `primitives/RepoCard.tsx` (12 violations)
- `NewDisplayConversationEntry.tsx` (11 violations)
- `primitives/CommandBar.tsx` (5 violations)

Would you like me to start refactoring these components to comply with the new rule?

* Done. The rule now only applies to `containers/`.

**Summary:**
- Removed the optional props ban from the general `ui-new/**` override
- Added a new override specifically for `src/components/ui-new/containers/**/*.{ts,tsx}`

**Impact reduced from 100 to 48 violations**, all now in container components:

| File | Violations |
|------|------------|
| SessionChatBoxContainer.tsx | 11 |
| DiffViewCardWithComments.tsx | 9 |
| SearchableDropdownContainer.tsx | 6 |
| FileTreeContainer.tsx | 3 |
| PreviewControlsContainer.tsx | 3 |
| ChangesPanelContainer.tsx | 2 |
| LogsContentContainer.tsx | 2 |
| PreviewBrowserContainer.tsx | 2 |
| WorkspacesLayout.tsx | 2 |
| WorkspacesMainContainer.tsx | 2 |
| + 6 more files | 6 |

* All ESLint issues in `SessionChatBoxContainer.tsx` have been fixed. Here's a summary of the changes:

1. **Removed dead code**:
   - Removed `taskId` prop (never passed by call site)
   - Removed `workspaceId` aliasing and fallback (`propWorkspaceId ?? session?.workspace_id`)

2. **Updated interface** - Changed all optional props (`?`) to explicit types:
   - Props that can be undefined use `Type | undefined` instead of `Type?`
   - Props with defaults (`sessions`, `isNewSessionMode`) are now required in the interface

3. **Added explicit defaults** in destructuring for all props:
   - `session = undefined`, `onSelectSession = undefined`, `projectId = undefined`, `onStartNewSession = undefined`, `workspaceId = undefined`
   - `filesChanged = 0`, `linesAdded = 0`, `linesRemoved = 0`
   - `sessions = []`, `isNewSessionMode = false`

* Done. I've refactored `SessionChatBoxContainer.tsx` to use a discriminated union for props. Here's a summary:

**Changes made:**

1. **Defined discriminated union type** with three modes:
   - `existing-session`: Has a `session` (required), `onSelectSession`, `onStartNewSession`
   - `new-session`: Has a `workspaceId` (required), `onSelectSession`
   - `placeholder`: Has `workspaceId` (optional), no callbacks

2. **Updated component** to extract mode-specific values from props based on the discriminant

3. **Updated call site** (`WorkspacesMain.tsx`) to pass the correct mode and props based on state

**Benefits:**
- No more redundant `= undefined` defaults for props that are required in their respective modes
- Type safety ensures each mode has exactly the props it needs
- Self-documenting code with explicit mode discriminant
- ESLint and TypeScript checks pass

* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6

* Both lint and type check pass. I've removed `workspaceId` from `PlaceholderProps` since it's not needed when the editor is disabled in placeholder mode.

* All tasks are complete. Here's a summary of the changes made:

## Summary

I've refactored `DiffViewCardWithComments.tsx` and its related files to fix 11 ESLint errors (9 in DiffViewCardWithComments, 1 in CommentWidgetLine, 1 in ReviewCommentRenderer).

### Changes Made:

1. **`DiffViewCardWithComments.tsx`** - Converted to use discriminated unions:
   - `DiffInput` type: Changed optional props (`oldPath?`, `changeKind?`, `hasLineNumbers?`) to explicit union types (`oldPath: string | undefined`)
   - `DiffViewCardWithCommentsProps`: Split into two modes using a discriminated union:
     - `CollapsibleProps` (mode: 'collapsible') - requires `expanded` and `onToggle`
     - `StaticProps` (mode: 'static') - always expanded, no toggle
   - Base props (`status`, `className`, `projectId`, `attemptId`) now use `T | undefined` instead of `T?`

2. **`CommentWidgetLine.tsx`** - Changed `projectId?: string` to `projectId: string | undefined`

3. **`ReviewCommentRenderer.tsx`** - Changed `projectId?: string` to `projectId: string | undefined`

4. **`ChangesPanel.tsx`** (callsite) - Updated to pass the new required props:
   - Added `mode="collapsible"`
   - Added `status={undefined}`
   - Added `className=""`

### Verification:
-  ESLint passes for all modified files
-  TypeScript type check passes

* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6

* Done. Here's a summary of all changes:

## Summary

Refactored `DiffViewCardWithComments.tsx` and its callsites to fix ESLint issues:

### Changes Made:

1. **`DiffViewCardWithComments.tsx`**:
   - Removed unused `status` prop and `ToolStatusDot` rendering
   - Made `projectId` and `attemptId` required strings (no longer `undefined`)
   - Removed conditional check for `attemptId` on the Open in IDE button (always shown now)

2. **`ChangesPanel.tsx`**:
   - Made `projectId` and `attemptId` required strings
   - Removed `status={undefined}` from the component call

3. **`CommentWidgetLine.tsx`**:
   - Changed `projectId: string | undefined` to `projectId: string`

4. **`ReviewCommentRenderer.tsx`**:
   - Changed `projectId: string | undefined` to `projectId: string`

5. **`ChangesPanelContainer.tsx`**:
   - Made `className` and `attemptId` required props
   - Added guard: if `projectId` is unavailable, renders empty panel

6. **`WorkspacesLayout.tsx`**:
   - Added guard: only renders `ChangesPanelContainer` when `selectedWorkspace?.id` exists
   - Passes required `className=""` prop

### Verification:
-  TypeScript type check passes
-  ESLint passes for all modified files

* Done. Changed `changeKind` from `DiffChangeKind | undefined` to `DiffChangeKind` in the `DiffInput` type. Both TypeScript and ESLint pass.

* Done. I've successfully refactored `SearchableDropdownContainer.tsx` to fix the 6 ESLint errors:

**Changes made:**

1. **`SearchableDropdownContainer.tsx`** (interface):
   - `selectedValue: string | null` (removed `?`)
   - `filterItem: ((item: T, query: string) => boolean) | null` (removed `?`, added `| null`)
   - `contentClassName: string` (removed `?`)
   - `placeholder: string` (removed `?`)
   - `emptyMessage: string` (removed `?`)
   - `getItemBadge: ((item: T) => string | undefined) | null` (removed `?`, added `| null`)

2. **`SearchableDropdownContainer.tsx`** (implementation):
   - Removed default values from destructuring (`placeholder = 'Search'`, `emptyMessage = 'No items found'`)
   - Changed `if (filterItem)` to `if (filterItem !== null)`
   - Changed `getItemBadge={getItemBadge}` to `getItemBadge={getItemBadge ?? undefined}`

3. **`RepoCardSimple.tsx`** (callsite):
   - Added `filterItem={null}`
   - Changed `selectedValue={selectedBranch}` to `selectedValue={selectedBranch ?? null}` to handle the case when `selectedBranch` is `undefined`

All ESLint errors for `SearchableDropdownContainer.tsx` are now resolved, and TypeScript compiles without errors.

* Refactor FileTreeContainer to fix ESLint errors for optional props

- Make all props required in FileTreeContainerProps interface
- Add guard in RightSidebar to only render Changes section when
  selectedWorkspace exists, ensuring workspaceId is always defined
- Remove redundant null check for onSelectFile callback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Refactor NewDisplayConversationEntry to fix ESLint errors for optional props

- Remove dead `task` prop from NewDisplayConversationEntry and ConversationListContainer
  (was only passed to legacy DisplayConversationEntry for entry types that don't use it)
- Make `executionProcessId` and `taskAttempt` required props
- Convert internal helper component optional props from `prop?: T` to `prop: T | undefined`
  to satisfy ESLint while preserving the same runtime behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Refactor VirtualizedProcessLogs to fix ESLint errors for optional props

Made search-related props required instead of optional:
- searchQuery: string (was optional)
- matchIndices: number[] (was optional)
- currentMatchIndex: number (was optional)

Updated callsites to provide explicit "no search" values where search
functionality is not used.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6

* Refactor container components to fix ESLint errors for optional props

Made the following props required (parents always provide them):
- BrowseRepoButtonContainer: disabled
- CopyButton: disabled
- WorkspacesMainContainer: isNewSessionMode, onStartNewSession
- LogsContentContainer: className
- PreviewBrowserContainer: attemptId, className
- PreviewControlsContainer: attemptId, className

Changed to union types (legitimately optional values):
- LogsContentContainer: command: string | undefined
- ProjectSelectorContainer: selectedProjectName: string | undefined

Added guards in parent components to only render children when
required data is available (RightSidebar, WorkspacesLayout).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6

* Refactor WorkspacesLayout to fix ESLint errors for optional props

Extract create mode sections into dedicated container components:
- CreateModeProjectSectionContainer: project selector with useCreateMode
- CreateModeReposSectionContainer: repos list with branch auto-selection
- CreateModeAddReposSectionContainer: add repos section

This removes the ModeProviderProps interface with optional props that
violated the ESLint rule. Now CreateModeProvider is only rendered when
in create mode, and each container manages its own context access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Cleanup script changes for workspace f4ec6f6e-a524-4760-889a-7ec2ebbce4c6

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Louis Knight-Webb
2026-01-17 13:14:08 +00:00
committed by GitHub
parent d1d0b7eacd
commit 14c9df626a
28 changed files with 474 additions and 416 deletions

View File

@@ -227,6 +227,20 @@ module.exports = {
],
},
},
{
// Container components should not have optional props
files: ['src/components/ui-new/containers/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'TSPropertySignature[optional=true]',
message:
'Optional props are not allowed in container components. Make the prop required or provide a default value.',
},
],
},
},
{
// Logic hooks in ui-new/hooks/ - no JSX allowed
files: ['src/components/ui-new/hooks/**/*.{ts,tsx}'],

View File

@@ -351,7 +351,13 @@ const ScriptFixerDialogImpl = NiceModal.create<ScriptFixerDialogProps>(
</div>
<div className="bg-secondary py-base flex-1 border rounded-md bg-muted overflow-hidden min-w-0">
{latestProcess ? (
<VirtualizedProcessLogs logs={logs} error={logsError} />
<VirtualizedProcessLogs
logs={logs}
error={logsError}
searchQuery=""
matchIndices={[]}
currentMatchIndex={-1}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
{t('scriptFixer.noLogs')}

View File

@@ -7,7 +7,7 @@ import { IconListItem } from '@/components/ui-new/primitives/IconListItem';
import type { Repo } from 'shared/types';
interface BrowseRepoButtonContainerProps {
disabled?: boolean;
disabled: boolean;
onRepoRegistered: (repo: Repo) => void;
}

View File

@@ -131,9 +131,9 @@ function useInViewObserver(
}
interface ChangesPanelContainerProps {
className?: string;
className: string;
/** Attempt ID for opening files in IDE */
attemptId?: string;
attemptId: string;
}
export function ChangesPanelContainer({
@@ -201,13 +201,27 @@ export function ChangesPanelContainer({
});
}, [diffs, processedPaths]);
// Guard: Don't render diffs until we have required data
const projectId = task?.project_id;
if (!projectId) {
return (
<ChangesPanel
ref={containerRef}
className={className}
diffItems={[]}
projectId=""
attemptId={attemptId}
/>
);
}
return (
<ChangesPanel
ref={containerRef}
className={className}
diffItems={diffItems}
onDiffRef={handleDiffRef}
projectId={task?.project_id}
projectId={projectId}
attemptId={attemptId}
/>
);

View File

@@ -10,7 +10,7 @@ interface CommentWidgetLineProps {
widgetKey: string;
onSave: () => void;
onCancel: () => void;
projectId?: string;
projectId: string;
}
export function CommentWidgetLine({

View File

@@ -17,17 +17,14 @@ import {
PatchTypeWithKey,
useConversationHistory,
} from '@/hooks/useConversationHistory';
import type { TaskWithAttemptStatus } from 'shared/types';
import type { WorkspaceWithSession } from '@/types/attempt';
interface ConversationListProps {
attempt: WorkspaceWithSession;
task?: TaskWithAttemptStatus;
}
interface MessageListContext {
attempt: WorkspaceWithSession;
task?: TaskWithAttemptStatus;
}
const INITIAL_TOP_ITEM = { index: 'LAST' as const, align: 'end' as const };
@@ -56,7 +53,6 @@ const ItemContent: VirtuosoMessageListProps<
MessageListContext
>['ItemContent'] = ({ data, context }) => {
const attempt = context?.attempt;
const task = context?.task;
if (data.type === 'STDOUT') {
return <p>{data.content}</p>;
@@ -64,14 +60,13 @@ const ItemContent: VirtuosoMessageListProps<
if (data.type === 'STDERR') {
return <p>{data.content}</p>;
}
if (data.type === 'NORMALIZED_ENTRY') {
if (data.type === 'NORMALIZED_ENTRY' && attempt) {
return (
<NewDisplayConversationEntry
expansionKey={data.patchKey}
entry={data.content}
executionProcessId={data.executionProcessId}
taskAttempt={attempt}
task={task}
/>
);
}
@@ -84,7 +79,7 @@ const computeItemKey: VirtuosoMessageListProps<
MessageListContext
>['computeItemKey'] = ({ data }) => `conv-${data.patchKey}`;
export function ConversationList({ attempt, task }: ConversationListProps) {
export function ConversationList({ attempt }: ConversationListProps) {
const [channelData, setChannelData] =
useState<DataWithScrollModifier<PatchTypeWithKey> | null>(null);
const [loading, setLoading] = useState(true);
@@ -149,10 +144,7 @@ export function ConversationList({ attempt, task }: ConversationListProps) {
useConversationHistory({ attempt, onEntriesUpdated });
const messageListRef = useRef<VirtuosoMessageListMethods | null>(null);
const messageListContext = useMemo(
() => ({ attempt, task }),
[attempt, task]
);
const messageListContext = useMemo(() => ({ attempt }), [attempt]);
// Determine if content is ready to show (has data or finished loading)
const hasContent = !loading || (channelData?.data?.length ?? 0) > 0;

View File

@@ -6,7 +6,7 @@ import { Tooltip } from '../primitives/Tooltip';
interface CopyButtonProps {
onCopy: () => void;
disabled?: boolean;
disabled: boolean;
}
/**

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateMode } from '@/contexts/CreateModeContext';
import { RecentReposListContainer } from './RecentReposListContainer';
import { BrowseRepoButtonContainer } from './BrowseRepoButtonContainer';
import { CreateRepoButtonContainer } from './CreateRepoButtonContainer';
export function CreateModeAddReposSectionContainer() {
const { t } = useTranslation(['common']);
const { repos, addRepo } = useCreateMode();
const registeredRepoPaths = useMemo(() => repos.map((r) => r.path), [repos]);
return (
<div className="flex flex-col gap-base p-base">
<p className="text-xs text-low font-medium">
{t('common:sections.recent')}
</p>
<RecentReposListContainer
registeredRepoPaths={registeredRepoPaths}
onRepoRegistered={addRepo}
/>
<p className="text-xs text-low font-medium">
{t('common:sections.other')}
</p>
<BrowseRepoButtonContainer disabled={false} onRepoRegistered={addRepo} />
<CreateRepoButtonContainer onRepoCreated={addRepo} />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { useCreateMode } from '@/contexts/CreateModeContext';
import { useProjects } from '@/hooks/useProjects';
import { ProjectSelectorContainer } from './ProjectSelectorContainer';
import { CreateProjectDialog } from '@/components/ui-new/dialogs/CreateProjectDialog';
export function CreateModeProjectSectionContainer() {
const { selectedProjectId, setSelectedProjectId, clearRepos } =
useCreateMode();
const { projects } = useProjects();
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const handleCreateProject = useCallback(async () => {
const result = await CreateProjectDialog.show({});
if (result.status === 'saved') {
setSelectedProjectId(result.project.id);
clearRepos();
}
}, [setSelectedProjectId, clearRepos]);
return (
<div className="p-base">
<ProjectSelectorContainer
projects={projects}
selectedProjectId={selectedProjectId}
selectedProjectName={selectedProject?.name}
onProjectSelect={(p) => setSelectedProjectId(p.id)}
onCreateProject={handleCreateProject}
/>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { WarningIcon } from '@phosphor-icons/react';
import { useCreateMode } from '@/contexts/CreateModeContext';
import { useMultiRepoBranches } from '@/hooks/useRepoBranches';
import { SelectedReposList } from '@/components/ui-new/primitives/SelectedReposList';
export function CreateModeReposSectionContainer() {
const { t } = useTranslation(['tasks']);
const { repos, removeRepo, targetBranches, setTargetBranch } =
useCreateMode();
const repoIds = useMemo(() => repos.map((r) => r.id), [repos]);
const { branchesByRepo } = useMultiRepoBranches(repoIds);
useEffect(() => {
repos.forEach((repo) => {
const branches = branchesByRepo[repo.id];
if (branches && !targetBranches[repo.id]) {
const currentBranch = branches.find((b) => b.is_current);
if (currentBranch) {
setTargetBranch(repo.id, currentBranch.name);
}
}
});
}, [repos, branchesByRepo, targetBranches, setTargetBranch]);
if (repos.length === 0) {
return (
<div className="p-base">
<div className="flex items-center gap-2 p-base rounded bg-warning/10 border border-warning/20">
<WarningIcon className="h-4 w-4 text-warning shrink-0" />
<p className="text-sm text-warning">
{t('gitPanel.create.warnings.noReposSelected')}
</p>
</div>
</div>
);
}
return (
<SelectedReposList
repos={repos}
onRemove={removeRepo}
branchesByRepo={branchesByRepo}
selectedBranches={targetBranches}
onBranchChange={setTargetBranch}
/>
);
}

View File

@@ -26,8 +26,7 @@ import {
import { CommentWidgetLine } from './CommentWidgetLine';
import { ReviewCommentRenderer } from './ReviewCommentRenderer';
import { GitHubCommentRenderer } from './GitHubCommentRenderer';
import type { ToolStatus, DiffChangeKind } from 'shared/types';
import { ToolStatusDot } from '../primitives/conversation/ToolStatusDot';
import type { DiffChangeKind } from 'shared/types';
import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton';
import { useOpenInEditor } from '@/hooks/useOpenInEditor';
import '@/styles/diff-style-overrides.css';
@@ -44,34 +43,45 @@ export type DiffInput =
type: 'content';
oldContent: string;
newContent: string;
oldPath?: string;
oldPath: string | undefined;
newPath: string;
changeKind?: DiffChangeKind;
changeKind: DiffChangeKind;
}
| {
type: 'unified';
path: string;
unifiedDiff: string;
hasLineNumbers?: boolean;
hasLineNumbers: boolean;
};
interface DiffViewCardWithCommentsProps {
/** Base props shared across all modes */
interface BaseProps {
/** Diff data - either raw content or unified diff string */
input: DiffInput;
/** Expansion state */
expanded?: boolean;
/** Toggle expansion callback */
onToggle?: () => void;
/** Optional status indicator */
status?: ToolStatus;
/** Additional className */
className?: string;
className: string;
/** Project ID for @ mentions in comments */
projectId?: string;
projectId: string;
/** Attempt ID for opening files in IDE */
attemptId?: string;
attemptId: string;
}
/** Props for collapsible mode (with expand/collapse) */
interface CollapsibleProps extends BaseProps {
mode: 'collapsible';
/** Expansion state */
expanded: boolean;
/** Toggle expansion callback */
onToggle: () => void;
}
/** Props for static mode (always expanded, no toggle) */
interface StaticProps extends BaseProps {
mode: 'static';
}
type DiffViewCardWithCommentsProps = CollapsibleProps | StaticProps;
interface DiffData {
diffFile: DiffFile | null;
additions: number;
@@ -168,15 +178,13 @@ function useDiffData(input: DiffInput): DiffData {
}, [input]);
}
export function DiffViewCardWithComments({
input,
expanded = false,
onToggle,
status,
className,
projectId,
attemptId,
}: DiffViewCardWithCommentsProps) {
export function DiffViewCardWithComments(props: DiffViewCardWithCommentsProps) {
const { input, className, projectId, attemptId, mode } = props;
// Extract mode-specific values
const expanded = mode === 'collapsible' ? props.expanded : true;
const onToggle = mode === 'collapsible' ? props.onToggle : undefined;
const { theme } = useTheme();
const actualTheme = getActualTheme(theme);
const globalMode = useDiffViewMode();
@@ -349,12 +357,6 @@ export function DiffViewCardWithComments({
>
<span className="relative shrink-0">
<FileIcon className="size-icon-base" />
{status && (
<ToolStatusDot
status={status}
className="absolute -bottom-0.5 -right-0.5"
/>
)}
</span>
{changeLabel && (
<span
@@ -406,14 +408,12 @@ export function DiffViewCardWithComments({
</span>
)}
<div className="flex items-center gap-1 shrink-0">
{attemptId && (
<span onClick={(e) => e.stopPropagation()}>
<OpenInIdeButton
onClick={handleOpenInIde}
className="size-icon-xs p-0"
/>
</span>
)}
{onToggle && (
<CaretDownIcon
className={cn(

View File

@@ -12,10 +12,10 @@ import { useChangesView } from '@/contexts/ChangesViewContext';
import type { Diff } from 'shared/types';
interface FileTreeContainerProps {
workspaceId?: string;
workspaceId: string;
diffs: Diff[];
onSelectFile?: (path: string, diff: Diff) => void;
className?: string;
onSelectFile: (path: string, diff: Diff) => void;
className: string;
}
export function FileTreeContainer({
@@ -118,7 +118,7 @@ export function FileTreeContainer({
setSelectedPath(path);
// Find the diff for this path
const diff = diffs.find((d) => d.newPath === path || d.oldPath === path);
if (diff && onSelectFile) {
if (diff) {
onSelectFile(path, diff);
}
},

View File

@@ -10,10 +10,15 @@ import { useLogsPanel } from '@/contexts/LogsPanelContext';
export type LogsPanelContent =
| { type: 'process'; processId: string }
| { type: 'tool'; toolName: string; content: string; command?: string };
| {
type: 'tool';
toolName: string;
content: string;
command: string | undefined;
};
interface LogsContentContainerProps {
className?: string;
className: string;
}
export function LogsContentContainer({ className }: LogsContentContainerProps) {

View File

@@ -6,7 +6,6 @@ import {
NormalizedEntry,
ToolStatus,
TodoItem,
type TaskWithAttemptStatus,
type RepoWithTargetBranch,
} from 'shared/types';
import type { WorkspaceWithSession } from '@/types/attempt';
@@ -42,9 +41,8 @@ import type { DiffInput } from '../primitives/conversation/DiffViewCard';
type Props = {
entry: NormalizedEntry;
expansionKey: string;
executionProcessId?: string;
taskAttempt?: WorkspaceWithSession;
task?: TaskWithAttemptStatus;
executionProcessId: string;
taskAttempt: WorkspaceWithSession;
};
type FileEditAction = Extract<ActionType, { action: 'file_edit' }>;
@@ -260,7 +258,7 @@ function renderToolUseEntry(
function NewDisplayConversationEntry(props: Props) {
const { t } = useTranslation('common');
const { entry, expansionKey, executionProcessId, taskAttempt, task } = props;
const { entry, expansionKey, executionProcessId, taskAttempt } = props;
const entryType = entry.entry_type;
switch (entryType.type) {
@@ -326,7 +324,6 @@ function NewDisplayConversationEntry(props: Props) {
expansionKey={expansionKey}
executionProcessId={executionProcessId}
taskAttempt={taskAttempt}
task={task}
/>
);
@@ -426,7 +423,7 @@ function PlanEntry({
}: {
plan: string;
expansionKey: string;
workspaceId?: string;
workspaceId: string | undefined;
status: ToolStatus;
}) {
const { t } = useTranslation('common');
@@ -470,7 +467,7 @@ function GenericToolApprovalEntry({
toolName: string;
content: string;
expansionKey: string;
workspaceId?: string;
workspaceId: string | undefined;
status: ToolStatus;
}) {
const [expanded, toggle] = usePersistedExpanded(
@@ -501,8 +498,8 @@ function UserMessageEntry({
}: {
content: string;
expansionKey: string;
workspaceId?: string;
executionProcessId?: string;
workspaceId: string | undefined;
executionProcessId: string | undefined;
}) {
const [expanded, toggle] = usePersistedExpanded(`user:${expansionKey}`, true);
const { startEdit, isEntryGreyed, isInEditMode } = useMessageEditContext();
@@ -538,7 +535,7 @@ function AssistantMessageEntry({
workspaceId,
}: {
content: string;
workspaceId?: string;
workspaceId: string | undefined;
}) {
return <ChatAssistantMessage content={content} workspaceId={workspaceId} />;
}
@@ -559,7 +556,7 @@ function ToolSummaryEntry({
status: ToolStatus;
content: string;
toolName: string;
command?: string;
command: string | undefined;
}) {
const [expanded, toggle] = usePersistedExpanded(
`tool:${expansionKey}`,
@@ -654,8 +651,8 @@ function ScriptEntryWithFix({
processId: string;
exitCode: number | null;
status: ToolStatus;
workspaceId?: string;
sessionId?: string;
workspaceId: string | undefined;
sessionId: string | undefined;
}) {
// Try to get repos from workspace context - may not be available in all contexts
let repos: RepoWithTargetBranch[] = [];

View File

@@ -27,8 +27,8 @@ const MIN_RESPONSIVE_WIDTH = 320;
const MIN_RESPONSIVE_HEIGHT = 480;
interface PreviewBrowserContainerProps {
attemptId?: string;
className?: string;
attemptId: string;
className: string;
}
export function PreviewBrowserContainer({

View File

@@ -10,8 +10,8 @@ import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
import { useLogsPanel } from '@/contexts/LogsPanelContext';
interface PreviewControlsContainerProps {
attemptId?: string;
className?: string;
attemptId: string;
className: string;
}
export function PreviewControlsContainer({

View File

@@ -17,7 +17,7 @@ import type { Project } from 'shared/types';
interface ProjectSelectorContainerProps {
projects: Project[];
selectedProjectId: string | null;
selectedProjectName?: string;
selectedProjectName: string | undefined;
onProjectSelect: (project: Project) => void;
onCreateProject: () => void;
}

View File

@@ -7,7 +7,7 @@ import { useReview, type ReviewComment } from '@/contexts/ReviewProvider';
interface ReviewCommentRendererProps {
comment: ReviewComment;
projectId?: string;
projectId: string;
}
export function ReviewCommentRenderer({

View File

@@ -1,22 +1,14 @@
import { useMemo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FileTreeContainer } from '@/components/ui-new/containers/FileTreeContainer';
import { ProcessListContainer } from '@/components/ui-new/containers/ProcessListContainer';
import { PreviewControlsContainer } from '@/components/ui-new/containers/PreviewControlsContainer';
import { GitPanelContainer } from '@/components/ui-new/containers/GitPanelContainer';
import { TerminalPanelContainer } from '@/components/ui-new/containers/TerminalPanelContainer';
import { ProjectSelectorContainer } from '@/components/ui-new/containers/ProjectSelectorContainer';
import { RecentReposListContainer } from '@/components/ui-new/containers/RecentReposListContainer';
import { BrowseRepoButtonContainer } from '@/components/ui-new/containers/BrowseRepoButtonContainer';
import { CreateRepoButtonContainer } from '@/components/ui-new/containers/CreateRepoButtonContainer';
import { CreateModeProjectSectionContainer } from '@/components/ui-new/containers/CreateModeProjectSectionContainer';
import { CreateModeReposSectionContainer } from '@/components/ui-new/containers/CreateModeReposSectionContainer';
import { CreateModeAddReposSectionContainer } from '@/components/ui-new/containers/CreateModeAddReposSectionContainer';
import { useChangesView } from '@/contexts/ChangesViewContext';
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
import { useCreateMode } from '@/contexts/CreateModeContext';
import { useMultiRepoBranches } from '@/hooks/useRepoBranches';
import { useProjects } from '@/hooks/useProjects';
import { CreateProjectDialog } from '@/components/ui-new/dialogs/CreateProjectDialog';
import { SelectedReposList } from '@/components/ui-new/primitives/SelectedReposList';
import { WarningIcon } from '@phosphor-icons/react';
import type { Workspace, RepoWithTargetBranch } from 'shared/types';
import {
RIGHT_MAIN_PANEL_MODES,
@@ -56,40 +48,6 @@ export function RightSidebar({
const { setExpanded } = useExpandedAll();
const isTerminalVisible = useUiPreferencesStore((s) => s.isTerminalVisible);
const {
repos: createRepos,
addRepo,
removeRepo,
clearRepos,
targetBranches,
setTargetBranch,
selectedProjectId,
setSelectedProjectId,
} = useCreateMode();
const { projects } = useProjects();
const repoIds = useMemo(() => createRepos.map((r) => r.id), [createRepos]);
const { branchesByRepo } = useMultiRepoBranches(repoIds);
useEffect(() => {
if (!isCreateMode) return;
createRepos.forEach((repo) => {
const branches = branchesByRepo[repo.id];
if (branches && !targetBranches[repo.id]) {
const currentBranch = branches.find((b) => b.is_current);
if (currentBranch) {
setTargetBranch(repo.id, currentBranch.name);
}
}
});
}, [
isCreateMode,
createRepos,
branchesByRepo,
targetBranches,
setTargetBranch,
]);
const [changesExpanded] = usePersistedExpanded(
PERSIST_KEYS.changesSection,
true
@@ -111,22 +69,6 @@ export function RightSidebar({
true
);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const registeredRepoPaths = useMemo(
() => createRepos.map((r) => r.path),
[createRepos]
);
const handleCreateProject = useCallback(async () => {
const result = await CreateProjectDialog.show({});
if (result.status === 'saved') {
setSelectedProjectId(result.project.id);
clearRepos();
}
}, [setSelectedProjectId, clearRepos]);
const hasNoRepos = createRepos.length === 0;
const hasUpperContent =
rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES ||
rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS ||
@@ -151,63 +93,21 @@ export function RightSidebar({
persistKey: PERSIST_KEYS.gitPanelProject,
visible: true,
expanded: true,
content: (
<div className="p-base">
<ProjectSelectorContainer
projects={projects}
selectedProjectId={selectedProjectId}
selectedProjectName={selectedProject?.name}
onProjectSelect={(p) => setSelectedProjectId(p.id)}
onCreateProject={handleCreateProject}
/>
</div>
),
content: <CreateModeProjectSectionContainer />,
},
{
title: t('common:sections.repositories'),
persistKey: PERSIST_KEYS.gitPanelRepositories,
visible: true,
expanded: true,
content: hasNoRepos ? (
<div className="p-base">
<div className="flex items-center gap-2 p-base rounded bg-warning/10 border border-warning/20">
<WarningIcon className="h-4 w-4 text-warning shrink-0" />
<p className="text-sm text-warning">
{t('gitPanel.create.warnings.noReposSelected')}
</p>
</div>
</div>
) : (
<SelectedReposList
repos={createRepos}
onRemove={removeRepo}
branchesByRepo={branchesByRepo}
selectedBranches={targetBranches}
onBranchChange={setTargetBranch}
/>
),
content: <CreateModeReposSectionContainer />,
},
{
title: t('common:sections.addRepositories'),
persistKey: PERSIST_KEYS.gitPanelAddRepositories,
visible: true,
expanded: true,
content: (
<div className="flex flex-col gap-base p-base">
<p className="text-xs text-low font-medium">
{t('common:sections.recent')}
</p>
<RecentReposListContainer
registeredRepoPaths={registeredRepoPaths}
onRepoRegistered={addRepo}
/>
<p className="text-xs text-low font-medium">
{t('common:sections.other')}
</p>
<BrowseRepoButtonContainer onRepoRegistered={addRepo} />
<CreateRepoButtonContainer onRepoCreated={addRepo} />
</div>
),
content: <CreateModeAddReposSectionContainer />,
},
]
: buildWorkspaceSections();
@@ -238,6 +138,7 @@ export function RightSidebar({
switch (rightMainPanelMode) {
case RIGHT_MAIN_PANEL_MODES.CHANGES:
if (selectedWorkspace) {
result.unshift({
title: 'Changes',
persistKey: PERSIST_KEYS.changesSection,
@@ -245,16 +146,18 @@ export function RightSidebar({
expanded: upperExpanded,
content: (
<FileTreeContainer
key={selectedWorkspace?.id}
workspaceId={selectedWorkspace?.id}
key={selectedWorkspace.id}
workspaceId={selectedWorkspace.id}
diffs={diffs}
onSelectFile={(path) => {
selectFile(path);
setExpanded(`diff:${path}`, true);
}}
className=""
/>
),
});
}
break;
case RIGHT_MAIN_PANEL_MODES.LOGS:
result.unshift({
@@ -266,15 +169,20 @@ export function RightSidebar({
});
break;
case RIGHT_MAIN_PANEL_MODES.PREVIEW:
if (selectedWorkspace) {
result.unshift({
title: 'Preview',
persistKey: PERSIST_KEYS.rightPanelPreview,
visible: hasUpperContent,
expanded: upperExpanded,
content: (
<PreviewControlsContainer attemptId={selectedWorkspace?.id} />
<PreviewControlsContainer
attemptId={selectedWorkspace.id}
className=""
/>
),
});
}
break;
case null:
break;

View File

@@ -6,14 +6,14 @@ interface SearchableDropdownContainerProps<T> {
/** Array of items to display */
items: T[];
/** Currently selected value (matched against getItemKey) */
selectedValue?: string | null;
selectedValue: string | null;
/** Extract unique key from item */
getItemKey: (item: T) => string;
/** Extract display label from item */
getItemLabel: (item: T) => string;
/** Custom filter function (defaults to label.includes(query)) */
filterItem?: (item: T, query: string) => boolean;
/** Custom filter function (null = default label.includes(query)) */
filterItem: ((item: T, query: string) => boolean) | null;
/** Called when an item is selected */
onSelect: (item: T) => void;
@@ -22,14 +22,14 @@ interface SearchableDropdownContainerProps<T> {
trigger: React.ReactNode;
/** Class name for dropdown content */
contentClassName?: string;
contentClassName: string;
/** Placeholder text for search input */
placeholder?: string;
placeholder: string;
/** Message shown when no items match */
emptyMessage?: string;
emptyMessage: string;
/** Optional badge text for each item */
getItemBadge?: (item: T) => string | undefined;
/** Badge text for each item (null = no badges) */
getItemBadge: ((item: T) => string | undefined) | null;
}
export function SearchableDropdownContainer<T>({
@@ -41,8 +41,8 @@ export function SearchableDropdownContainer<T>({
onSelect,
trigger,
contentClassName,
placeholder = 'Search',
emptyMessage = 'No items found',
placeholder,
emptyMessage,
getItemBadge,
}: SearchableDropdownContainerProps<T>) {
const [searchTerm, setSearchTerm] = useState('');
@@ -53,7 +53,7 @@ export function SearchableDropdownContainer<T>({
const filteredItems = useMemo(() => {
if (!searchTerm.trim()) return items;
const query = searchTerm.toLowerCase();
if (filterItem) {
if (filterItem !== null) {
return items.filter((item) => filterItem(item, query));
}
return items.filter((item) =>
@@ -160,7 +160,7 @@ export function SearchableDropdownContainer<T>({
contentClassName={contentClassName}
placeholder={placeholder}
emptyMessage={emptyMessage}
getItemBadge={getItemBadge}
getItemBadge={getItemBadge ?? undefined}
/>
);
}

View File

@@ -58,45 +58,68 @@ function computeExecutionStatus(params: {
return 'idle';
}
interface SessionChatBoxContainerProps {
/** The current session */
session?: Session;
/** Task ID for execution tracking */
taskId?: string;
/** Number of files changed in current session */
filesChanged?: number;
/** Number of lines added */
linesAdded?: number;
/** Number of lines removed */
linesRemoved?: number;
/** Shared props across all modes */
interface SharedProps {
/** Available sessions for this workspace */
sessions?: Session[];
/** Called when a session is selected */
onSelectSession?: (sessionId: string) => void;
sessions: Session[];
/** Project ID for file search in typeahead */
projectId?: string;
/** Whether user is creating a new session */
isNewSessionMode?: boolean;
/** Callback to start new session mode */
onStartNewSession?: () => void;
/** Workspace ID for creating new sessions */
workspaceId?: string;
projectId: string | undefined;
/** Number of files changed in current session */
filesChanged: number;
/** Number of lines added */
linesAdded: number;
/** Number of lines removed */
linesRemoved: number;
}
export function SessionChatBoxContainer({
session,
taskId,
filesChanged,
linesAdded,
linesRemoved,
sessions = [],
onSelectSession,
projectId,
isNewSessionMode = false,
onStartNewSession,
workspaceId: propWorkspaceId,
}: SessionChatBoxContainerProps) {
const workspaceId = propWorkspaceId ?? session?.workspace_id;
/** Props for existing session mode */
interface ExistingSessionProps extends SharedProps {
mode: 'existing-session';
/** The current session */
session: Session;
/** Called when a session is selected */
onSelectSession: (sessionId: string) => void;
/** Callback to start new session mode */
onStartNewSession: (() => void) | undefined;
}
/** Props for new session mode */
interface NewSessionProps extends SharedProps {
mode: 'new-session';
/** Workspace ID for creating new sessions */
workspaceId: string;
/** Called when a session is selected */
onSelectSession: (sessionId: string) => void;
}
/** Props for placeholder mode (no workspace selected) */
interface PlaceholderProps extends SharedProps {
mode: 'placeholder';
}
type SessionChatBoxContainerProps =
| ExistingSessionProps
| NewSessionProps
| PlaceholderProps;
export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) {
const { mode, sessions, projectId, filesChanged, linesAdded, linesRemoved } =
props;
// Extract mode-specific values
const session = mode === 'existing-session' ? props.session : undefined;
const workspaceId =
mode === 'existing-session'
? props.session.workspace_id
: mode === 'new-session'
? props.workspaceId
: undefined;
const isNewSessionMode = mode === 'new-session';
const onSelectSession =
mode === 'placeholder' ? undefined : props.onSelectSession;
const onStartNewSession =
mode === 'existing-session' ? props.onStartNewSession : undefined;
const sessionId = session?.id;
const queryClient = useQueryClient();
@@ -151,7 +174,7 @@ export function SessionChatBoxContainer({
// Execution state
const { isAttemptRunning, stopExecution, isStopping, processes } =
useAttemptExecution(workspaceId, taskId);
useAttemptExecution(workspaceId);
// Approval feedback context
const feedbackContext = useApprovalFeedbackOptional();
@@ -557,12 +580,8 @@ export function SessionChatBoxContainer({
localMessage,
]);
// Render placeholder state if no session and not in new session mode
// This maintains the visual structure during workspace transitions
const isPlaceholderMode = !session && !isNewSessionMode;
// In placeholder mode, render a disabled version to maintain visual structure
if (isPlaceholderMode) {
if (mode === 'placeholder') {
return (
<SessionChatBox
status="idle"

View File

@@ -20,17 +20,17 @@ export type LogEntry = Extract<
export interface VirtualizedProcessLogsProps {
logs: LogEntry[];
error: string | null;
searchQuery?: string;
matchIndices?: number[];
currentMatchIndex?: number;
searchQuery: string;
matchIndices: number[];
currentMatchIndex: number;
}
type LogEntryWithKey = LogEntry & { key: string; originalIndex: number };
interface SearchContext {
searchQuery?: string;
matchIndices?: number[];
currentMatchIndex?: number;
searchQuery: string;
matchIndices: number[];
currentMatchIndex: number;
}
const INITIAL_TOP_ITEM = { index: 'LAST' as const, align: 'end' as const };
@@ -55,10 +55,9 @@ const ItemContent: VirtuosoMessageListProps<
LogEntryWithKey,
SearchContext
>['ItemContent'] = ({ data, context }) => {
const isMatch = context?.matchIndices?.includes(data.originalIndex) ?? false;
const isMatch = context.matchIndices.includes(data.originalIndex);
const isCurrentMatch =
context?.matchIndices?.[context?.currentMatchIndex ?? -1] ===
data.originalIndex;
context.matchIndices[context.currentMatchIndex] === data.originalIndex;
return (
<RawLogText
@@ -66,7 +65,7 @@ const ItemContent: VirtuosoMessageListProps<
channel={data.type === 'STDERR' ? 'stderr' : 'stdout'}
className="text-sm px-4 py-1"
linkifyUrls
searchQuery={isMatch ? context?.searchQuery : undefined}
searchQuery={isMatch ? context.searchQuery : undefined}
isCurrentMatch={isCurrentMatch}
/>
);
@@ -121,9 +120,8 @@ export function VirtualizedProcessLogs({
// Scroll to current match when it changes
useEffect(() => {
if (
matchIndices &&
matchIndices.length > 0 &&
currentMatchIndex !== undefined &&
currentMatchIndex >= 0 &&
currentMatchIndex !== prevCurrentMatchRef.current
) {
const logIndex = matchIndices[currentMatchIndex];

View File

@@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from 'react';
import { useEffect } from 'react';
import { Group, Layout, Panel, Separator } from 'react-resizable-panels';
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext';
@@ -29,37 +29,6 @@ import { useCommandBarShortcut } from '@/hooks/useCommandBarShortcut';
const WORKSPACES_GUIDE_ID = 'workspaces-guide';
interface ModeProviderProps {
isCreateMode: boolean;
executionProps: {
key: string;
attemptId?: string;
sessionId?: string;
};
children: ReactNode;
}
function ModeProvider({
isCreateMode,
executionProps,
children,
}: ModeProviderProps) {
if (isCreateMode) {
return <CreateModeProvider>{children}</CreateModeProvider>;
}
return (
<CreateModeProvider>
<ExecutionProcessesProvider
key={executionProps.key}
attemptId={executionProps.attemptId}
sessionId={executionProps.sessionId}
>
{children}
</ExecutionProcessesProvider>
</CreateModeProvider>
);
}
export function WorkspacesLayout() {
const {
workspaceId,
@@ -135,25 +104,7 @@ export function WorkspacesLayout() {
setRightMainPanelSize(layout['right-main']);
};
return (
<div className="flex flex-col h-screen">
<NavbarContainer />
<div className="flex flex-1 min-h-0">
{isLeftSidebarVisible && (
<div className="w-[300px] shrink-0 h-full overflow-hidden">
<WorkspacesSidebarContainer />
</div>
)}
<div className="flex-1 min-w-0 h-full">
<ModeProvider
isCreateMode={isCreateMode}
executionProps={{
key: `${selectedWorkspace?.id}-${selectedSessionId}`,
attemptId: selectedWorkspace?.id,
sessionId: selectedSessionId,
}}
>
const mainContent = (
<ReviewProvider attemptId={selectedWorkspace?.id}>
<LogsPanelProvider>
<ChangesViewProvider>
@@ -186,8 +137,7 @@ export function WorkspacesLayout() {
</Panel>
)}
{isLeftMainPanelVisible &&
rightMainPanelMode !== null && (
{isLeftMainPanelVisible && rightMainPanelMode !== null && (
<Separator
id="main-separator"
className="w-1 bg-transparent hover:bg-brand/50 transition-colors cursor-col-resize"
@@ -200,20 +150,21 @@ export function WorkspacesLayout() {
minSize={20}
className="min-w-0 h-full overflow-hidden"
>
{rightMainPanelMode ===
RIGHT_MAIN_PANEL_MODES.CHANGES && (
{rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES &&
selectedWorkspace?.id && (
<ChangesPanelContainer
attemptId={selectedWorkspace?.id}
className=""
attemptId={selectedWorkspace.id}
/>
)}
{rightMainPanelMode ===
RIGHT_MAIN_PANEL_MODES.LOGS && (
<LogsContentContainer />
{rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS && (
<LogsContentContainer className="" />
)}
{rightMainPanelMode ===
RIGHT_MAIN_PANEL_MODES.PREVIEW && (
{rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW &&
selectedWorkspace?.id && (
<PreviewBrowserContainer
attemptId={selectedWorkspace?.id}
attemptId={selectedWorkspace.id}
className=""
/>
)}
</Panel>
@@ -234,7 +185,30 @@ export function WorkspacesLayout() {
</ChangesViewProvider>
</LogsPanelProvider>
</ReviewProvider>
</ModeProvider>
);
return (
<div className="flex flex-col h-screen">
<NavbarContainer />
<div className="flex flex-1 min-h-0">
{isLeftSidebarVisible && (
<div className="w-[300px] shrink-0 h-full overflow-hidden">
<WorkspacesSidebarContainer />
</div>
)}
<div className="flex-1 min-w-0 h-full">
{isCreateMode ? (
<CreateModeProvider>{mainContent}</CreateModeProvider>
) : (
<ExecutionProcessesProvider
key={`${selectedWorkspace?.id}-${selectedSessionId}`}
attemptId={selectedWorkspace?.id}
sessionId={selectedSessionId}
>
{mainContent}
</ExecutionProcessesProvider>
)}
</div>
</div>
</div>

View File

@@ -12,9 +12,9 @@ interface WorkspacesMainContainerProps {
onSelectSession: (sessionId: string) => void;
isLoading: boolean;
/** Whether user is creating a new session */
isNewSessionMode?: boolean;
isNewSessionMode: boolean;
/** Callback to start new session mode */
onStartNewSession?: () => void;
onStartNewSession: () => void;
}
export function WorkspacesMainContainer({

View File

@@ -46,9 +46,10 @@ export function RepoCardSimple({
{branches && onBranchChange && (
<SearchableDropdownContainer
items={branches}
selectedValue={selectedBranch}
selectedValue={selectedBranch ?? null}
getItemKey={(b) => b.name}
getItemLabel={(b) => b.name}
filterItem={null}
getItemBadge={(b) => (b.is_current ? 'Current' : undefined)}
onSelect={(b) => onBranchChange(b.name)}
placeholder="Search"

View File

@@ -16,9 +16,9 @@ interface ChangesPanelProps {
diffItems: DiffItemData[];
onDiffRef?: (path: string, el: HTMLDivElement | null) => void;
/** Project ID for @ mentions in comments */
projectId?: string;
projectId: string;
/** Attempt ID for opening files in IDE */
attemptId?: string;
attemptId: string;
}
// Memoized DiffItem - only re-renders when its specific diff reference changes
@@ -32,8 +32,8 @@ const DiffItem = memo(function DiffItem({
diff: Diff;
initialExpanded?: boolean;
onRef?: (path: string, el: HTMLDivElement | null) => void;
projectId?: string;
attemptId?: string;
projectId: string;
attemptId: string;
}) {
const path = diff.newPath || diff.oldPath || '';
const [expanded, toggle] = usePersistedExpanded(
@@ -55,9 +55,11 @@ const DiffItem = memo(function DiffItem({
return (
<div ref={(el) => onRef?.(path, el)}>
<DiffViewCardWithComments
mode="collapsible"
input={input}
expanded={expanded}
onToggle={toggle}
className=""
projectId={projectId}
attemptId={attemptId}
/>

View File

@@ -81,7 +81,13 @@ export function PreviewControls({
<SpinnerIcon className="size-icon-sm animate-spin" />
</div>
) : devServerProcesses.length > 0 ? (
<VirtualizedProcessLogs logs={logs} error={logsError} />
<VirtualizedProcessLogs
logs={logs}
error={logsError}
searchQuery=""
matchIndices={[]}
currentMatchIndex={-1}
/>
) : null}
</div>
</div>

View File

@@ -83,16 +83,27 @@ export function WorkspacesMain({
{/* Chat box - always rendered to prevent flash during workspace switch */}
<div className="flex justify-center @container pl-px">
<SessionChatBoxContainer
session={session}
{...(isNewSessionMode && workspaceWithSession
? {
mode: 'new-session',
workspaceId: workspaceWithSession.id,
onSelectSession,
}
: session
? {
mode: 'existing-session',
session,
onSelectSession,
onStartNewSession,
}
: {
mode: 'placeholder',
})}
sessions={sessions}
onSelectSession={onSelectSession}
filesChanged={diffStats?.filesChanged}
linesAdded={diffStats?.linesAdded}
linesRemoved={diffStats?.linesRemoved}
projectId={projectId}
isNewSessionMode={isNewSessionMode}
onStartNewSession={onStartNewSession}
workspaceId={workspaceWithSession?.id}
filesChanged={diffStats?.filesChanged ?? 0}
linesAdded={diffStats?.linesAdded ?? 0}
linesRemoved={diffStats?.linesRemoved ?? 0}
/>
</div>
</MessageEditProvider>