mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat: add resizable chat panel (#563)
This commit is contained in:
parent
241846e1ef
commit
e8b63ecec1
19 changed files with 335 additions and 5 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export const ar: Dict = {
|
|||
|
||||
'project.backToProjects': 'العودة للمشاريع',
|
||||
'project.metaFreeform': 'شكل حر',
|
||||
'project.resizeChatPanel': 'تغيير حجم لوحة الدردشة',
|
||||
'chat.tabChat': 'دردشة',
|
||||
'chat.tabComments': 'تعليقات',
|
||||
'chat.commentsSoon': 'التعليقات - قريباً',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ export const fa: Dict = {
|
|||
|
||||
'project.backToProjects': 'بازگشت به پروژهها',
|
||||
'project.metaFreeform': 'آزاد',
|
||||
'project.resizeChatPanel': 'تغییر اندازه پنل چت',
|
||||
'chat.tabChat': 'چت',
|
||||
'chat.tabComments': 'نظرات',
|
||||
'chat.commentsSoon': 'نظرات — به زودی',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ export const ja: Dict = {
|
|||
|
||||
'project.backToProjects': 'プロジェクトに戻る',
|
||||
'project.metaFreeform': 'フリーフォーム',
|
||||
'project.resizeChatPanel': 'チャットパネルのサイズを変更',
|
||||
'chat.tabChat': 'チャット',
|
||||
'chat.tabComments': 'コメント',
|
||||
'chat.commentsSoon': 'コメント — 近日公開',
|
||||
|
|
|
|||
|
|
@ -394,6 +394,7 @@ export const ko: Dict = {
|
|||
|
||||
'project.backToProjects': '프로젝트 목록으로 돌아가기',
|
||||
'project.metaFreeform': '자유 양식',
|
||||
'project.resizeChatPanel': '채팅 패널 크기 조절',
|
||||
'chat.tabChat': '채팅',
|
||||
'chat.tabComments': '댓글',
|
||||
'chat.commentsSoon': '댓글 — 곧 지원될 예정입니다.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@ export const ru: Dict = {
|
|||
|
||||
'project.backToProjects': 'Назад к проектам',
|
||||
'project.metaFreeform': 'произвольная форма',
|
||||
'project.resizeChatPanel': 'Изменить размер панели чата',
|
||||
'chat.tabChat': 'Чат',
|
||||
'chat.tabComments': 'Комментарии',
|
||||
'chat.commentsSoon': 'Комментарии — скоро',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ export const uk: Dict = {
|
|||
|
||||
'project.backToProjects': 'Назад до проектів',
|
||||
'project.metaFreeform': 'вільна форма',
|
||||
'project.resizeChatPanel': 'Змінити розмір панелі чату',
|
||||
'chat.tabChat': 'Чат',
|
||||
'chat.tabComments': 'Коментарі',
|
||||
'chat.commentsSoon': 'Коментарі — скоро',
|
||||
|
|
|
|||
|
|
@ -399,6 +399,7 @@ export const zhCN: Dict = {
|
|||
|
||||
'project.backToProjects': '返回项目列表',
|
||||
'project.metaFreeform': '自由设计',
|
||||
'project.resizeChatPanel': '调整聊天面板大小',
|
||||
'chat.tabChat': '对话',
|
||||
'chat.tabComments': '评论',
|
||||
'chat.commentsSoon': '评论 — 即将上线',
|
||||
|
|
|
|||
|
|
@ -399,6 +399,7 @@ export const zhTW: Dict = {
|
|||
|
||||
'project.backToProjects': '返回專案列表',
|
||||
'project.metaFreeform': '自由設計',
|
||||
'project.resizeChatPanel': '調整聊天面板大小',
|
||||
'chat.tabChat': '對話',
|
||||
'chat.tabComments': '評論',
|
||||
'chat.commentsSoon': '評論 — 即將上線',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue