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:
committed by
GitHub
parent
d1d0b7eacd
commit
14c9df626a
@@ -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}'],
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface CommentWidgetLineProps {
|
||||
widgetKey: string;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function CommentWidgetLine({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tooltip } from '../primitives/Tooltip';
|
||||
|
||||
interface CopyButtonProps {
|
||||
onCopy: () => void;
|
||||
disabled?: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useReview, type ReviewComment } from '@/contexts/ReviewProvider';
|
||||
|
||||
interface ReviewCommentRendererProps {
|
||||
comment: ReviewComment;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ReviewCommentRenderer({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user