feat: add resizable chat panel (#563)

This commit is contained in:
ferasbusiness666 2026-05-06 05:12:45 +03:00 committed by GitHub
parent 241846e1ef
commit e8b63ecec1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 335 additions and 5 deletions

View file

@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useLayoutEffect,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest';
import { createArtifactParser } from '../artifacts/parser';
import { useT } from '../i18n';
@ -105,6 +114,62 @@ interface Props {
}
let liveArtifactEventSequence = 0;
const CHAT_PANEL_WIDTH_STORAGE_KEY = 'open-design.project.chatPanelWidth';
const DEFAULT_CHAT_PANEL_WIDTH = 460;
const MIN_CHAT_PANEL_WIDTH = 320;
const MAX_CHAT_PANEL_WIDTH = 720;
const MIN_WORKSPACE_PANEL_WIDTH = 400;
const SPLIT_RESIZE_HANDLE_WIDTH = 8;
const CHAT_PANEL_KEYBOARD_STEP = 16;
const MIN_NORMAL_SPLIT_WIDTH =
MIN_CHAT_PANEL_WIDTH + SPLIT_RESIZE_HANDLE_WIDTH + MIN_WORKSPACE_PANEL_WIDTH;
function workspacePanelMinWidthForSplit(splitWidth: number): number {
if (!Number.isFinite(splitWidth) || splitWidth <= 0) return MIN_WORKSPACE_PANEL_WIDTH;
return splitWidth < MIN_NORMAL_SPLIT_WIDTH ? 0 : MIN_WORKSPACE_PANEL_WIDTH;
}
function maxChatPanelWidthForSplit(splitWidth: number): number {
if (!Number.isFinite(splitWidth) || splitWidth <= 0) return MAX_CHAT_PANEL_WIDTH;
const workspaceMinWidth = workspacePanelMinWidthForSplit(splitWidth);
const viewportAwareMax = splitWidth - SPLIT_RESIZE_HANDLE_WIDTH - workspaceMinWidth;
return Math.max(0, Math.min(MAX_CHAT_PANEL_WIDTH, Math.floor(viewportAwareMax)));
}
function clampPreferredChatPanelWidth(width: number): number {
return Math.min(MAX_CHAT_PANEL_WIDTH, Math.max(MIN_CHAT_PANEL_WIDTH, Math.round(width)));
}
function clampChatPanelWidth(width: number, maxWidth = MAX_CHAT_PANEL_WIDTH): number {
const effectiveMax = Math.max(0, Math.min(MAX_CHAT_PANEL_WIDTH, Math.floor(maxWidth)));
const effectiveMin = Math.min(MIN_CHAT_PANEL_WIDTH, effectiveMax);
return Math.min(effectiveMax, Math.max(effectiveMin, Math.round(width)));
}
function readSavedChatPanelWidth(): number {
if (typeof window === 'undefined') return DEFAULT_CHAT_PANEL_WIDTH;
try {
const raw = window.localStorage.getItem(CHAT_PANEL_WIDTH_STORAGE_KEY);
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
return Number.isFinite(parsed)
? clampPreferredChatPanelWidth(parsed)
: DEFAULT_CHAT_PANEL_WIDTH;
} catch {
return DEFAULT_CHAT_PANEL_WIDTH;
}
}
function saveChatPanelWidth(width: number): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
CHAT_PANEL_WIDTH_STORAGE_KEY,
String(clampPreferredChatPanelWidth(width)),
);
} catch {
// localStorage can be unavailable in hardened browser contexts.
}
}
function appendLiveArtifactEventItem(
prev: LiveArtifactEventItem[],
@ -176,6 +241,24 @@ export function ProjectView({
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const [liveArtifacts, setLiveArtifacts] = useState<LiveArtifactSummary[]>([]);
const [liveArtifactEvents, setLiveArtifactEvents] = useState<LiveArtifactEventItem[]>([]);
const [chatPanelWidth, setChatPanelWidth] = useState(readSavedChatPanelWidth);
const [chatPanelMaxWidth, setChatPanelMaxWidth] = useState(MAX_CHAT_PANEL_WIDTH);
const [workspacePanelMinWidth, setWorkspacePanelMinWidth] = useState(MIN_WORKSPACE_PANEL_WIDTH);
const [resizingChatPanel, setResizingChatPanel] = useState(false);
const splitRef = useRef<HTMLDivElement | null>(null);
const chatPanelWidthRef = useRef(chatPanelWidth);
const preferredChatPanelWidthRef = useRef(chatPanelWidth);
const resizeStartPreferredWidthRef = useRef(chatPanelWidth);
const chatPanelMaxWidthRef = useRef(chatPanelMaxWidth);
const resizeStateRef = useRef<{
startClientX: number;
startWidth: number;
isRtl: boolean;
hasMoved: boolean;
} | null>(null);
const pointerCleanupRef = useRef<(() => void) | null>(null);
const pointerFrameRef = useRef<number | null>(null);
const pendingPointerClientXRef = useRef<number | null>(null);
// The persisted set of open tabs + active tab. Persisted via PUT on every
// change; loaded once when the project mounts.
const [openTabsState, setOpenTabsState] = useState<OpenTabsState>({
@ -1425,6 +1508,175 @@ export function ProjectView({
() => skills.find((s) => s.id === project.skillId)?.mode === 'deck',
[skills, project.skillId],
);
const chatResizeLabel = t('project.resizeChatPanel');
const workspacePanelTrack =
workspacePanelMinWidth === 0
? 'minmax(0, 1fr)'
: `minmax(${workspacePanelMinWidth}px, 1fr)`;
const chatPanelAriaMinWidth = Math.min(MIN_CHAT_PANEL_WIDTH, chatPanelMaxWidth);
const renderPreferredChatPanelWidth = useCallback((
preferredWidth: number,
maxWidth = chatPanelMaxWidthRef.current,
): number => {
const next = clampChatPanelWidth(preferredWidth, maxWidth);
chatPanelWidthRef.current = next;
setChatPanelWidth(next);
return next;
}, []);
const applyChatPanelWidth = useCallback((width: number): number => {
const nextPreferred = clampPreferredChatPanelWidth(
clampChatPanelWidth(width, chatPanelMaxWidthRef.current),
);
preferredChatPanelWidthRef.current = nextPreferred;
return renderPreferredChatPanelWidth(nextPreferred);
}, [renderPreferredChatPanelWidth]);
const finishChatPanelResize = useCallback((saveFinalWidth = true) => {
pointerCleanupRef.current?.();
pointerCleanupRef.current = null;
if (pointerFrameRef.current !== null) {
cancelAnimationFrame(pointerFrameRef.current);
pointerFrameRef.current = null;
}
pendingPointerClientXRef.current = null;
resizeStateRef.current = null;
setResizingChatPanel(false);
if (saveFinalWidth) saveChatPanelWidth(preferredChatPanelWidthRef.current);
}, []);
useEffect(() => {
chatPanelWidthRef.current = chatPanelWidth;
}, [chatPanelWidth]);
useEffect(() => {
chatPanelMaxWidthRef.current = chatPanelMaxWidth;
}, [chatPanelMaxWidth]);
useLayoutEffect(() => {
const split = splitRef.current;
if (!split) return undefined;
const updateAllowedWidth = () => {
const splitWidth = split.clientWidth;
const nextWorkspaceMin = workspacePanelMinWidthForSplit(splitWidth);
const nextMax = maxChatPanelWidthForSplit(splitWidth);
chatPanelMaxWidthRef.current = nextMax;
setWorkspacePanelMinWidth(nextWorkspaceMin);
setChatPanelMaxWidth(nextMax);
renderPreferredChatPanelWidth(preferredChatPanelWidthRef.current, nextMax);
};
updateAllowedWidth();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateAllowedWidth);
observer.observe(split);
return () => observer.disconnect();
}
window.addEventListener('resize', updateAllowedWidth);
return () => window.removeEventListener('resize', updateAllowedWidth);
}, [renderPreferredChatPanelWidth]);
useEffect(() => () => finishChatPanelResize(false), [finishChatPanelResize]);
const handleChatResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const split = splitRef.current;
if (!split) return;
event.preventDefault();
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
pointerCleanupRef.current?.();
setResizingChatPanel(true);
resizeStartPreferredWidthRef.current = preferredChatPanelWidthRef.current;
const updateWidthFromClientX = (clientX: number) => {
const state = resizeStateRef.current;
if (!state) return;
const delta = clientX - state.startClientX;
if (delta === 0 && !state.hasMoved) return;
state.hasMoved = true;
const rawWidth = state.startWidth + (state.isRtl ? -delta : delta);
applyChatPanelWidth(rawWidth);
};
const flushPendingPointerMove = () => {
if (pointerFrameRef.current !== null) {
cancelAnimationFrame(pointerFrameRef.current);
pointerFrameRef.current = null;
}
const clientX = pendingPointerClientXRef.current;
pendingPointerClientXRef.current = null;
if (clientX !== null) updateWidthFromClientX(clientX);
};
resizeStateRef.current = {
startClientX: event.clientX,
startWidth: chatPanelWidthRef.current,
isRtl: window.getComputedStyle(split).direction === 'rtl',
hasMoved: false,
};
const handlePointerMove = (moveEvent: PointerEvent) => {
pendingPointerClientXRef.current = moveEvent.clientX;
if (pointerFrameRef.current !== null) return;
pointerFrameRef.current = requestAnimationFrame(() => {
pointerFrameRef.current = null;
flushPendingPointerMove();
});
};
const handlePointerEnd = () => {
flushPendingPointerMove();
finishChatPanelResize(true);
};
const handlePointerCancel = () => {
flushPendingPointerMove();
preferredChatPanelWidthRef.current = resizeStartPreferredWidthRef.current;
renderPreferredChatPanelWidth(resizeStartPreferredWidthRef.current);
finishChatPanelResize(false);
};
const cleanup = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerEnd);
window.removeEventListener('pointercancel', handlePointerCancel);
window.removeEventListener('blur', handlePointerCancel);
};
pointerCleanupRef.current = cleanup;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerEnd);
window.addEventListener('pointercancel', handlePointerCancel);
window.addEventListener('blur', handlePointerCancel);
}, [applyChatPanelWidth, finishChatPanelResize, renderPreferredChatPanelWidth]);
const handleChatResizeBlur = useCallback(() => {
if (!pointerCleanupRef.current) return;
preferredChatPanelWidthRef.current = resizeStartPreferredWidthRef.current;
renderPreferredChatPanelWidth(resizeStartPreferredWidthRef.current);
finishChatPanelResize(false);
}, [finishChatPanelResize, renderPreferredChatPanelWidth]);
const handleChatResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
let nextWidth: number | null = null;
const split = splitRef.current;
const isRtl = split ? window.getComputedStyle(split).direction === 'rtl' : false;
if (event.key === 'ArrowLeft') {
nextWidth = chatPanelWidthRef.current + (isRtl ? 1 : -1) * CHAT_PANEL_KEYBOARD_STEP;
} else if (event.key === 'ArrowRight') {
nextWidth = chatPanelWidthRef.current + (isRtl ? -1 : 1) * CHAT_PANEL_KEYBOARD_STEP;
} else if (event.key === 'Home') {
nextWidth = MIN_CHAT_PANEL_WIDTH;
} else if (event.key === 'End') {
nextWidth = chatPanelMaxWidthRef.current;
}
if (nextWidth === null) return;
event.preventDefault();
const next = applyChatPanelWidth(nextWidth);
saveChatPanelWidth(next);
}, [applyChatPanelWidth]);
// Hand the pending prompt to ChatPane exactly once. We snapshot the value
// into local state on mount so it survives the ChatPane remount triggered
@ -1486,7 +1738,14 @@ export function ProjectView({
<span className="meta" data-testid="project-meta">{projectMeta}</span>
</div>
</AppChromeHeader>
<div className="split">
<div
ref={splitRef}
className={`split${resizingChatPanel ? ' is-resizing-chat' : ''}`}
style={{
gridTemplateColumns:
`${chatPanelWidth}px ${SPLIT_RESIZE_HANDLE_WIDTH}px ${workspacePanelTrack}`,
}}
>
<ChatPane
// The conversation id is part of the key so switching conversations
// resets internal scroll/draft state inside ChatPane and ChatComposer.
@ -1528,6 +1787,20 @@ export function ProjectView({
onProjectChange({ ...project, metadata });
}}
/>
<div
className="split-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label={chatResizeLabel}
aria-valuemin={chatPanelAriaMinWidth}
aria-valuemax={chatPanelMaxWidth}
aria-valuenow={chatPanelWidth}
tabIndex={0}
title={chatResizeLabel}
onPointerDown={handleChatResizePointerDown}
onKeyDown={handleChatResizeKeyDown}
onBlur={handleChatResizeBlur}
/>
<FileWorkspace
projectId={project.id}
files={projectFiles}

View file

@ -394,6 +394,7 @@ export const ar: Dict = {
'project.backToProjects': 'العودة للمشاريع',
'project.metaFreeform': 'شكل حر',
'project.resizeChatPanel': 'تغيير حجم لوحة الدردشة',
'chat.tabChat': 'دردشة',
'chat.tabComments': 'تعليقات',
'chat.commentsSoon': 'التعليقات - قريباً',

View file

@ -348,6 +348,7 @@ export const de: Dict = {
'project.backToProjects': 'Zurück zu Projekten',
'project.metaFreeform': 'frei',
'project.resizeChatPanel': 'Größe des Chat-Bereichs ändern',
'chat.tabChat': 'Chat',
'chat.tabComments': 'Kommentare',
'chat.commentsSoon': 'Kommentare — demnächst',

View file

@ -405,6 +405,7 @@ export const en: Dict = {
'project.backToProjects': 'Back to projects',
'project.metaFreeform': 'freeform',
'project.resizeChatPanel': 'Resize chat panel',
'chat.tabChat': 'Chat',
'chat.tabComments': 'Comments',
'chat.commentsSoon': 'Comments — coming soon',

View file

@ -349,6 +349,7 @@ export const esES: Dict = {
'project.backToProjects': 'Volver a los proyectos',
'project.metaFreeform': 'estilo libre',
'project.resizeChatPanel': 'Redimensionar panel de chat',
'chat.tabChat': 'Chat',
'chat.tabComments': 'Comentarios',
'chat.commentsSoon': 'Comentarios — próximamente',

View file

@ -405,6 +405,7 @@ export const fa: Dict = {
'project.backToProjects': 'بازگشت به پروژه‌ها',
'project.metaFreeform': 'آزاد',
'project.resizeChatPanel': 'تغییر اندازه پنل چت',
'chat.tabChat': 'چت',
'chat.tabComments': 'نظرات',
'chat.commentsSoon': 'نظرات — به زودی',

View file

@ -394,6 +394,7 @@ export const fr: Dict = {
'project.backToProjects': 'Retour aux projets',
'project.metaFreeform': 'libre',
'project.resizeChatPanel': 'Redimensionner le panneau de chat',
'chat.tabChat': 'Chat',
'chat.tabComments': 'Commentaires',
'chat.commentsSoon': 'Commentaires — bientôt disponible',

View file

@ -394,6 +394,7 @@ export const hu: Dict = {
'project.backToProjects': 'Vissza a projektekhez',
'project.metaFreeform': 'szabad formátum',
'project.resizeChatPanel': 'Csevegőpanel átméretezése',
'chat.tabChat': 'Csevegés',
'chat.tabComments': 'Megjegyzések',
'chat.commentsSoon': 'Megjegyzések — hamarosan',

View file

@ -347,6 +347,7 @@ export const ja: Dict = {
'project.backToProjects': 'プロジェクトに戻る',
'project.metaFreeform': 'フリーフォーム',
'project.resizeChatPanel': 'チャットパネルのサイズを変更',
'chat.tabChat': 'チャット',
'chat.tabComments': 'コメント',
'chat.commentsSoon': 'コメント — 近日公開',

View file

@ -394,6 +394,7 @@ export const ko: Dict = {
'project.backToProjects': '프로젝트 목록으로 돌아가기',
'project.metaFreeform': '자유 양식',
'project.resizeChatPanel': '채팅 패널 크기 조절',
'chat.tabChat': '채팅',
'chat.tabComments': '댓글',
'chat.commentsSoon': '댓글 — 곧 지원될 예정입니다.',

View file

@ -394,6 +394,7 @@ export const pl: Dict = {
'project.backToProjects': 'Wróć do projektów',
'project.metaFreeform': 'styl dowolny',
'project.resizeChatPanel': 'Zmień rozmiar panelu czatu',
'chat.tabChat': 'Czat',
'chat.tabComments': 'Komentarze',
'chat.commentsSoon': 'Komentarze — wkrótce',

View file

@ -404,6 +404,7 @@ export const ptBR: Dict = {
'project.backToProjects': 'Voltar aos projetos',
'project.metaFreeform': 'livre',
'project.resizeChatPanel': 'Redimensionar painel de chat',
'chat.tabChat': 'Chat',
'chat.tabComments': 'Comentários',
'chat.commentsSoon': 'Comentários — em breve',

View file

@ -404,6 +404,7 @@ export const ru: Dict = {
'project.backToProjects': 'Назад к проектам',
'project.metaFreeform': 'произвольная форма',
'project.resizeChatPanel': 'Изменить размер панели чата',
'chat.tabChat': 'Чат',
'chat.tabComments': 'Комментарии',
'chat.commentsSoon': 'Комментарии — скоро',

View file

@ -393,6 +393,7 @@ export const tr: Dict = {
'project.backToProjects': 'Projelere dön',
'project.metaFreeform': 'serbest stil',
'project.resizeChatPanel': 'Sohbet panelini yeniden boyutlandır',
'chat.tabChat': 'Sohbet',
'chat.tabComments': 'Yorumlar',
'chat.commentsSoon': 'Yorumlar — yakında',

View file

@ -405,6 +405,7 @@ export const uk: Dict = {
'project.backToProjects': 'Назад до проектів',
'project.metaFreeform': 'вільна форма',
'project.resizeChatPanel': 'Змінити розмір панелі чату',
'chat.tabChat': 'Чат',
'chat.tabComments': 'Коментарі',
'chat.commentsSoon': 'Коментарі — скоро',

View file

@ -399,6 +399,7 @@ export const zhCN: Dict = {
'project.backToProjects': '返回项目列表',
'project.metaFreeform': '自由设计',
'project.resizeChatPanel': '调整聊天面板大小',
'chat.tabChat': '对话',
'chat.tabComments': '评论',
'chat.commentsSoon': '评论 — 即将上线',

View file

@ -399,6 +399,7 @@ export const zhTW: Dict = {
'project.backToProjects': '返回專案列表',
'project.metaFreeform': '自由設計',
'project.resizeChatPanel': '調整聊天面板大小',
'chat.tabChat': '對話',
'chat.tabComments': '評論',
'chat.commentsSoon': '評論 — 即將上線',

View file

@ -468,6 +468,7 @@ export interface Dict {
// Project view / chat pane / composer
'project.backToProjects': string;
'project.metaFreeform': string;
'project.resizeChatPanel': string;
'chat.tabChat': string;
'chat.tabComments': string;
'chat.commentsSoon': string;

View file

@ -580,17 +580,57 @@ code {
/* -------- Split / panes -------------------------------------------- */
.split {
display: grid;
grid-template-columns: minmax(380px, 460px) minmax(0, 1fr);
grid-template-columns: 460px 8px minmax(400px, 1fr);
min-height: 0;
min-width: 0;
}
.split.is-resizing-chat {
cursor: col-resize;
user-select: none;
}
.split.is-resizing-chat iframe {
pointer-events: none;
}
.pane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--border);
background: var(--bg-panel);
}
.pane:last-child { border-right: none; }
.split-resize-handle {
width: 8px;
min-width: 8px;
height: 100%;
cursor: col-resize;
background:
linear-gradient(
90deg,
transparent 0,
transparent 3px,
var(--border) 3px,
var(--border) 5px,
transparent 5px
);
transition: background 120ms ease;
touch-action: none;
}
.split-resize-handle:hover,
.split-resize-handle:focus-visible,
.split.is-resizing-chat .split-resize-handle {
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--accent) 16%, transparent) 0,
color-mix(in srgb, var(--accent) 16%, transparent) 3px,
var(--accent) 3px,
var(--accent) 5px,
color-mix(in srgb, var(--accent) 16%, transparent) 5px
);
}
.split-resize-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* -------- Chat sticky header --------------------------------------- */
.chat-header {