feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)

* feat: tabbed right sidebar, context panel, floating diff comments

* fix: auto-close left sidebar when context panel opens

- Increase default context panel width from 520 to 600 pixels
- Increase sidebar minimum width from 200 to 300 pixels
- Replace collapsible component with custom button in diff view

* refactoring: rework sidebars, tabs, and file tree layout

- Rewrite AnimatedTabs as segment-style with sliding indicator
- Upgrade SidebarFilesTree to match FilesView features (context menus,
  git status, file icons, CRUD dialogs, fuzzy search ranking)
- Restructure FilesView header: tabs row + actions row, remove breadcrumbs
- Show relative path in context panel header, track active tab
- Allow left sidebar to stay open alongside context panel
- Hide diff/files tabs from header on desktop (mobile-only)
- Move chevron after group name in session sidebar
- Compact tab heights in right sidebar and git view
- Size PreviewToggleButton to match other action buttons
- Remove directory loading spinner from folder icons

* feat: add project icon and color customization

- Enable users to assign custom icons to projects
- Allow users to choose accent colors for projects
- Stabilize repo status UI during project switching

* feat: add scroll fade indicators to editor tabs

* style: reduce spacing and icon sizes in header

* style: adjust tab component padding from uniform to vertical-horizontal

* feat: Add session state indicators to project tabs

* feat: Enhance session status handling and improve UI responsiveness

* fix: preserve upstream tracking on branch rename

* fix: improve initial remote selection for pull requests

- Uses saved remote name from previous session when available
- Selects remote based on tracking branch when possible
- Falls back to origin or first available remote

* perf(diff): faster highlight, stable stacked scroll

- split/unified Pierre worker pools; prefer shiki-wasm
- align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks
- harden stacked pin/align (cancel on user scroll/input); prevent overscroll
- make overlay scrollbar MutationObserver optional; disable for diff container

* feat: handle binary files in diff view

* fix: adjust project tabs layout and drag regions

* style: update drag overlay visual styling

* feat: enable number keys to switch projects in the sidebar

* fix: recognize octet-stream as text-based MIME type

* feat: add keyboard navigation to context panel

* feat: add session pinning to sidebar

- Pin important sessions to keep them at the top
- Pinned sessions persist across browser sessions

* refactor: move context usage display from chat input to header
This commit is contained in:
Bohdan Triapitsyn
2026-02-16 14:15:19 +02:00
committed by GitHub
parent 12606b9e53
commit 47c943b487
42 changed files with 4874 additions and 1163 deletions
@@ -1,5 +1,6 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useShallow } from 'zustand/react/shallow';
import ChatMessage from './ChatMessage';
import { PermissionCard } from './PermissionCard';
@@ -10,6 +11,7 @@ import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScro
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
import { useSessionStore } from '@/stores/useSessionStore';
interface ChatMessageEntry {
info: Message;
@@ -211,7 +213,7 @@ const MessageList: React.FC<MessageListProps> = ({
onMessageContentChange('permission');
}, [permissions, questions, onMessageContentChange]);
const displayMessages = React.useMemo(() => {
const baseDisplayMessages = React.useMemo(() => {
const seenIds = new Set<string>();
return messages
.filter((message) => {
@@ -238,6 +240,101 @@ const MessageList: React.FC<MessageListProps> = ({
});
}, [messages]);
const activeRetryStatus = useSessionStore(
useShallow((state) => {
const sessionId = state.currentSessionId;
if (!sessionId) return null;
const status = state.sessionStatus?.get(sessionId);
if (!status || status.type !== 'retry') return null;
const rawMessage = typeof status.message === 'string' ? status.message.trim() : '';
return {
sessionId,
message: rawMessage || 'Quota limit reached. Retrying automatically.',
confirmedAt: status.confirmedAt,
};
})
);
const displayMessages = React.useMemo(() => {
if (!activeRetryStatus) {
return baseDisplayMessages;
}
const retryError = {
name: 'SessionRetry',
message: activeRetryStatus.message,
data: { message: activeRetryStatus.message },
};
const resolveRole = (message: ChatMessageEntry): string | null => {
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
return (typeof info.clientRole === 'string' ? info.clientRole : null)
?? (typeof info.role === 'string' ? info.role : null)
?? null;
};
let lastUserIndex = -1;
for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
if (resolveRole(baseDisplayMessages[index]) === 'user') {
lastUserIndex = index;
break;
}
}
if (lastUserIndex < 0) {
return baseDisplayMessages;
}
// Prefer attaching retry error to the assistant message in the current turn (if one exists)
// to avoid rendering a separate header-only placeholder + error block.
let targetAssistantIndex = -1;
for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
if (resolveRole(baseDisplayMessages[index]) === 'assistant') {
targetAssistantIndex = index;
break;
}
}
if (targetAssistantIndex >= 0) {
const existing = baseDisplayMessages[targetAssistantIndex];
const existingInfo = existing.info as unknown as { error?: unknown };
if (existingInfo.error) {
return baseDisplayMessages;
}
return baseDisplayMessages.map((message, index) => {
if (index !== targetAssistantIndex) {
return message;
}
return {
...message,
info: {
...(message.info as unknown as Record<string, unknown>),
error: retryError,
} as unknown as Message,
};
});
}
const eventTime = typeof activeRetryStatus.confirmedAt === 'number' ? activeRetryStatus.confirmedAt : Date.now();
const syntheticId = `synthetic_retry_notice_${activeRetryStatus.sessionId}`;
const synthetic: ChatMessageEntry = {
info: {
id: syntheticId,
sessionID: activeRetryStatus.sessionId,
role: 'assistant',
time: { created: eventTime, completed: eventTime },
finish: 'stop',
error: retryError,
} as unknown as Message,
parts: [],
};
const next = baseDisplayMessages.slice();
next.splice(lastUserIndex + 1, 0, synthetic);
return next;
}, [activeRetryStatus, baseDisplayMessages]);
const { turns, ungroupedMessages } = React.useMemo(() => {
const groupedTurns = detectTurns(displayMessages);
const groupedMessageIds = new Set<string>();