feat(core): add a resizeable split view for ai chat (#12896)

The visibility of preview panel is controlled by `showPreviewPanel` in
`ChatPanel`, but there is no entrance to open it in this PR.

![CleanShot 2025-06-23 at 15 13
39](https://github.com/user-attachments/assets/fc0e9ecf-a64d-4a21-8e10-7e838cd9e985)



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a split-view layout in the chat panel, allowing users to
view both the chat and a new preview panel side by side.
- Added a draggable divider for resizing the chat and preview panels,
with the divider position saved automatically for future sessions.

- **Refactor**
- Updated the chat panel interface to support the new split-view and
preview panel functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-02 18:15:23 +08:00
committed by GitHub
parent f761cbd964
commit a59448ec4b
3 changed files with 267 additions and 1 deletions

View File

@@ -102,6 +102,8 @@ export class ChatPanel extends SignalWatcher(
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = signal(undefined);
@state()
accessor showPreviewPanel = false;
private readonly initSession = async () => {
if (this.session) {
@@ -244,7 +246,7 @@ export class ChatPanel extends SignalWatcher(
: nothing}
`;
return html`<div class="chat-panel-container" style=${style}>
const left = html`<div class="chat-panel-container" style=${style}>
${keyed(
this.doc.id,
html`<ai-chat-content
@@ -266,6 +268,15 @@ export class ChatPanel extends SignalWatcher(
></ai-chat-content>`
)}
</div>`;
const right = html`<div>Preview Panel</div>`;
return html`<chat-panel-split-view
.left=${left}
.right=${right}
.open=${this.showPreviewPanel}
>
</chat-panel-split-view>`;
}
}

View File

@@ -0,0 +1,253 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { property, query, state } from 'lit/decorators.js';
export class ChatPanelSplitView extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.ai-chat-panel-split-view {
--gap: 0px;
--drag-size: 10px;
display: flex;
align-items: stretch;
height: 100%;
}
.ai-chat-panel-split-view[data-dragging='true'] {
cursor: col-resize;
}
.ai-chat-panel-split-view-left,
.ai-chat-panel-split-view-right,
.ai-chat-panel-split-view-divider {
flex-shrink: 0;
flex-grow: 0;
}
.ai-chat-panel-split-view-left,
.ai-chat-panel-split-view-right {
transition: width 0.23s ease;
}
.ai-chat-panel-split-view[data-dragging='true']
.ai-chat-panel-split-view-left,
.ai-chat-panel-split-view[data-dragging='true']
.ai-chat-panel-split-view-right {
transition: none;
}
.ai-chat-panel-split-view-divider {
width: var(--gap);
position: relative;
border-left: 0.5px solid var(--affine-v2-layer-insideBorder-border);
}
.ai-chat-panel-split-view-divider[data-open='false'] {
width: 0;
visibility: hidden;
pointer-events: none;
}
.ai-chat-panel-split-view-divider-handle {
width: var(--drag-size);
height: 100%;
position: absolute;
top: 0;
left: calc((var(--drag-size) - var(--gap)) / 2 * -1);
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
}
.ai-chat-panel-split-view-divider-handle::after {
content: '';
width: 2px;
height: 100%;
background-color: var(--affine-v2-button-primary);
opacity: 0;
transition:
opacity 0.23s ease,
width 0.23s ease;
}
.ai-chat-panel-split-view[data-dragging='true']
.ai-chat-panel-split-view-divider-handle::after {
width: 4px;
opacity: 1;
}
.ai-chat-panel-split-view-divider-handle:hover::after {
opacity: 1;
}
`;
@property({ attribute: false })
accessor minWidthPercent: number = 20;
@property({ attribute: false })
accessor open: boolean = false;
@property({ attribute: false })
accessor left: TemplateResult<1> | null = null;
@property({ attribute: false })
accessor right: TemplateResult<1> | null = null;
@query('.ai-chat-panel-split-view-divider-handle')
private accessor _handle!: HTMLElement;
@query('.ai-chat-panel-split-view-left')
private accessor _left!: HTMLElement;
@query('.ai-chat-panel-split-view-right')
private accessor _right!: HTMLElement;
@state()
accessor isDragging = false;
@state()
accessor isTransitioning = false;
private readonly _storeKey = 'chat-panel-split-view-size';
private _getInitialSize() {
try {
const last = localStorage.getItem(this._storeKey);
return last ? Number.parseInt(last) : 50;
} catch {
return 50;
}
}
private _setInitialSize(size: number) {
try {
localStorage.setItem(this._storeKey, size.toString());
} catch {
console.error('Failed to set initial size');
}
}
private _percent = this._getInitialSize();
private _initialBox: DOMRect | null = null;
private _initialX: number | null = null;
private _initialPercent: number | null = null;
private _rafId: number | null = null;
private _onDragStart(x: number) {
this.isDragging = true;
this._initialBox = this.getBoundingClientRect();
this._initialX = x;
this._initialPercent = this._percent;
}
private _onDragMove(x: number) {
const offset = x - (this._initialX || 0);
const offsetPercent = (offset / (this._initialBox?.width || 1)) * 100;
this._percent = Math.max(
this.minWidthPercent,
Math.min(
100 - this.minWidthPercent,
Number(((this._initialPercent || 0) + offsetPercent).toFixed(0))
)
);
this._updateSize();
}
private _onDragEnd() {
this.isDragging = false;
this._setInitialSize(this._percent);
}
private _updateSize() {
if (this._rafId) {
cancelAnimationFrame(this._rafId);
}
this._rafId = requestAnimationFrame(() => {
if (this.open && this._left && this._right) {
const leftPercent = this._percent;
const rightPercent = 100 - leftPercent;
this._left.style.width = `${leftPercent}%`;
this._right.style.width = `${rightPercent}%`;
}
if (!this.open && this._left) {
this._left.style.width = '100%';
}
});
}
override firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
if (this._left) {
this.disposables.addFromEvent(this._left, 'transitionstart', () => {
this.isTransitioning = true;
});
this.disposables.addFromEvent(this._left, 'transitionend', () => {
this.isTransitioning = false;
});
}
if (this._handle) {
// mouse
let onMouseMove = (e: MouseEvent) => {
this._onDragMove(e.clientX);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
this._onDragEnd();
};
this.disposables.addFromEvent(this._handle, 'mousedown', e => {
e.stopPropagation();
e.preventDefault();
this._onDragStart(e.clientX);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
// touch
let onTouchMove = (e: TouchEvent) => {
this._onDragMove(e.touches[0].clientX);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
this._onDragEnd();
};
this.disposables.addFromEvent(this._handle, 'touchstart', e => {
e.stopPropagation();
e.preventDefault();
this._onDragStart(e.touches[0].clientX);
document.addEventListener('touchmove', onTouchMove);
document.addEventListener('touchend', onTouchEnd);
});
}
}
override updated(changed: PropertyValues) {
super.updated(changed);
if (changed.has('open')) {
this._updateSize();
}
}
override render() {
return html`<div
class="ai-chat-panel-split-view"
data-open=${this.open}
data-dragging=${this.isDragging}
>
<div class="ai-chat-panel-split-view-left">${this.left}</div>
<div class="ai-chat-panel-split-view-divider">
<div class="ai-chat-panel-split-view-divider-handle"></div>
</div>
${this.open || this.isTransitioning
? html` <div class="ai-chat-panel-split-view-right">${this.right}</div>`
: nothing}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'chat-panel-split-view': ChatPanelSplitView;
}
}

View File

@@ -23,6 +23,7 @@ import { AILoading } from './chat-panel/ai-loading';
import { ChatMessageAction } from './chat-panel/message/action';
import { ChatMessageAssistant } from './chat-panel/message/assistant';
import { ChatMessageUser } from './chat-panel/message/user';
import { ChatPanelSplitView } from './chat-panel/split-view';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
@@ -184,4 +185,5 @@ export function registerAIEffects() {
);
customElements.define('transcription-block', LitTranscriptionBlock);
customElements.define('chat-panel-split-view', ChatPanelSplitView);
}