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
+115 -16
View File
@@ -3426,15 +3426,12 @@ const startGlobalEventWatcher = async () => {
// Update authoritative session state from OpenCode events
if (payload && payload.type === 'session.status') {
const status = payload.properties?.status;
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
const eventId = payload.properties?.eventId || `sse-${Date.now()}`;
if (typeof sessionId === 'string' && status?.type) {
updateSessionState(sessionId, status.type, eventId, {
attempt: status.attempt,
message: status.message,
next: status.next
const update = extractSessionStatusUpdate(payload);
if (update) {
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
}
}
@@ -3686,6 +3683,84 @@ function parseSseDataPayload(block) {
}
}
function extractSessionStatusUpdate(payload) {
if (!payload || typeof payload !== 'object' || payload.type !== 'session.status') {
return null;
}
const props = payload.properties ?? {};
const status =
props.status ??
props.session?.status ??
props.sessionInfo?.status;
const metadata =
props.metadata ??
(typeof status === 'object' && status !== null ? status.metadata : null);
const sessionId = props.sessionID ?? props.sessionId;
if (typeof sessionId !== 'string' || sessionId.length === 0) {
return null;
}
const statusType =
typeof status === 'string'
? status
: typeof status?.type === 'string'
? status.type
: typeof status?.status === 'string'
? status.status
: typeof props.type === 'string'
? props.type
: typeof props.phase === 'string'
? props.phase
: typeof props.state === 'string'
? props.state
: null;
const normalizedType =
statusType === 'idle' || statusType === 'busy' || statusType === 'retry'
? statusType
: null;
if (!normalizedType) {
return null;
}
const attempt =
typeof status?.attempt === 'number'
? status.attempt
: typeof props.attempt === 'number'
? props.attempt
: typeof metadata?.attempt === 'number'
? metadata.attempt
: undefined;
const message =
typeof status?.message === 'string'
? status.message
: typeof props.message === 'string'
? props.message
: typeof metadata?.message === 'string'
? metadata.message
: undefined;
const next =
typeof status?.next === 'number'
? status.next
: typeof props.next === 'number'
? props.next
: typeof metadata?.next === 'number'
? metadata.next
: undefined;
return {
sessionId,
type: normalizedType,
attempt,
message,
next,
eventId: typeof props.eventId === 'string' ? props.eventId : null,
};
}
function emitDesktopNotification(payload) {
if (!ENV_DESKTOP_NOTIFY) {
return;
@@ -3759,13 +3834,10 @@ function deriveSessionActivityTransitions(payload) {
}
if (payload.type === 'session.status') {
const status = payload.properties?.status;
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
const statusType = status?.type;
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
return [{ sessionId, phase }];
const update = extractSessionStatusUpdate(payload);
if (update) {
const phase = update.type === 'busy' || update.type === 'retry' ? 'busy' : 'idle';
return [{ sessionId: update.sessionId, phase }];
}
}
@@ -6059,6 +6131,20 @@ async function main(options = {}) {
const payload = parseSseDataPayload(block);
// Cache session titles from session.updated/session.created events (global stream)
maybeCacheSessionInfoFromEvent(payload);
// Keep server-authoritative session state fresh even if the
// background watcher is disconnected.
if (payload && payload.type === 'session.status') {
const update = extractSessionStatusUpdate(payload);
if (update) {
updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
}
}
const transitions = deriveSessionActivityTransitions(payload);
if (transitions && transitions.length > 0) {
for (const activity of transitions) {
@@ -6187,6 +6273,18 @@ async function main(options = {}) {
const payload = parseSseDataPayload(block);
// Cache session titles from session.updated/session.created events (per-session stream)
maybeCacheSessionInfoFromEvent(payload);
if (payload && payload.type === 'session.status') {
const update = extractSessionStatusUpdate(payload);
if (update) {
updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
}
}
const transitions = deriveSessionActivityTransitions(payload);
if (transitions && transitions.length > 0) {
for (const activity of transitions) {
@@ -8902,6 +9000,7 @@ async function main(options = {}) {
original: result.original,
modified: result.modified,
path: result.path,
isBinary: Boolean(result.isBinary),
});
} catch (error) {
console.error('Failed to get git file diff:', error);