fix(core): ui state (#14933)
#### PR Dependency Tree * **PR #14933** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added draft tab option to AI chat interface * Introduced "Current document" session history view in chat history popover * Added control to show/hide "New Chat" button * **Improvements** * Enhanced chat history preservation when switching between sessions * Prevented duplicate session creation requests * Improved message handling during session transitions and generation [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14933) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -85,6 +85,18 @@ class NonThrottledController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(CloudThrottlerGuard)
|
||||||
|
@Throttle('strict')
|
||||||
|
@Controller('/strict-throttled')
|
||||||
|
class StrictThrottledController {
|
||||||
|
@Public()
|
||||||
|
@SkipThrottle()
|
||||||
|
@Get('/skip')
|
||||||
|
skip() {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
const app = await createTestingApp({
|
const app = await createTestingApp({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -100,7 +112,11 @@ test.before(async t => {
|
|||||||
}),
|
}),
|
||||||
AppModule,
|
AppModule,
|
||||||
],
|
],
|
||||||
controllers: [ThrottledController, NonThrottledController],
|
controllers: [
|
||||||
|
ThrottledController,
|
||||||
|
NonThrottledController,
|
||||||
|
StrictThrottledController,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
t.context.storage = app.get(ThrottlerStorage);
|
t.context.storage = app.get(ThrottlerStorage);
|
||||||
@@ -244,6 +260,18 @@ test('should skip throttler for unauthenticated user when specified', async t =>
|
|||||||
t.is(headers.reset, undefined!);
|
t.is(headers.reset, undefined!);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should skip class-level strict throttler when specified', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
|
||||||
|
const res = await app.GET('/strict-throttled/skip').expect(200);
|
||||||
|
|
||||||
|
const headers = rateLimitHeaders(res);
|
||||||
|
|
||||||
|
t.is(headers.limit, undefined!);
|
||||||
|
t.is(headers.remaining, undefined!);
|
||||||
|
t.is(headers.reset, undefined!);
|
||||||
|
});
|
||||||
|
|
||||||
test('should use specified throttler for unauthenticated user', async t => {
|
test('should use specified throttler for unauthenticated user', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
||||||
import { SkipThrottle, Throttle as RawThrottle } from '@nestjs/throttler';
|
import {
|
||||||
|
SkipThrottle as RawSkipThrottle,
|
||||||
|
Throttle as RawThrottle,
|
||||||
|
} from '@nestjs/throttler';
|
||||||
|
|
||||||
import { ThrottlerType } from './config';
|
import { ThrottlerType } from './config';
|
||||||
|
|
||||||
@@ -38,4 +41,11 @@ export function Throttle(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { SkipThrottle };
|
export function SkipThrottle(
|
||||||
|
skip: Partial<Record<ThrottlerType, boolean>> = {
|
||||||
|
default: true,
|
||||||
|
strict: true,
|
||||||
|
}
|
||||||
|
): MethodDecorator & ClassDecorator {
|
||||||
|
return RawSkipThrottle(skip);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AIProvider } from '../../provider';
|
||||||
import { AIChatContent } from './ai-chat-content';
|
import { AIChatContent } from './ai-chat-content';
|
||||||
|
|
||||||
|
const originalHistories = AIProvider.histories;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
AIProvider.provide('histories', originalHistories as any);
|
||||||
|
});
|
||||||
|
|
||||||
describe('AIChatContent pinned scroll tracking', () => {
|
describe('AIChatContent pinned scroll tracking', () => {
|
||||||
test('records scroll position from the chat messages host', async () => {
|
test('records scroll position from the chat messages host', async () => {
|
||||||
let scrollEndHandler: (() => void) | undefined;
|
let scrollEndHandler: (() => void) | undefined;
|
||||||
@@ -39,3 +46,104 @@ describe('AIChatContent pinned scroll tracking', () => {
|
|||||||
expect((content as any).lastScrollTop).toBe(256);
|
expect((content as any).lastScrollTop).toBe(256);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AIChatContent history loading', () => {
|
||||||
|
test('replaces messages when the active session changes', async () => {
|
||||||
|
const histories = {
|
||||||
|
chats: vi.fn(async (_workspaceId: string, sessionId: string) => [
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: `${sessionId}-message`,
|
||||||
|
role: 'user',
|
||||||
|
content: sessionId,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
actions: vi.fn(async () => []),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
ids: vi.fn(),
|
||||||
|
};
|
||||||
|
AIProvider.provide('histories', histories as any);
|
||||||
|
|
||||||
|
const content: {
|
||||||
|
updateHistoryCounter: number;
|
||||||
|
historyKey: string | undefined;
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
session: { sessionId: string };
|
||||||
|
chatContextValue: { messages: unknown[]; status?: string };
|
||||||
|
updateContext: (context: { messages: unknown[] }) => void;
|
||||||
|
} = {
|
||||||
|
updateHistoryCounter: 0,
|
||||||
|
historyKey: undefined,
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
docId: 'doc-1',
|
||||||
|
session: { sessionId: 'session-1' },
|
||||||
|
chatContextValue: { messages: [] },
|
||||||
|
updateContext(context: { messages: unknown[] }) {
|
||||||
|
this.chatContextValue = {
|
||||||
|
...this.chatContextValue,
|
||||||
|
...context,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await (AIChatContent.prototype as any).updateHistory.call(content);
|
||||||
|
expect(
|
||||||
|
content.chatContextValue.messages.map((message: any) => message.id)
|
||||||
|
).toEqual(['session-1-message']);
|
||||||
|
|
||||||
|
content.session = { sessionId: 'session-2' };
|
||||||
|
await (AIChatContent.prototype as any).updateHistory.call(content);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
content.chatContextValue.messages.map((message: any) => message.id)
|
||||||
|
).toEqual(['session-2-message']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not overwrite in-flight optimistic messages when a session is created', async () => {
|
||||||
|
const histories = {
|
||||||
|
chats: vi.fn(async () => [{ messages: [] }]),
|
||||||
|
actions: vi.fn(async () => []),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
ids: vi.fn(),
|
||||||
|
};
|
||||||
|
AIProvider.provide('histories', histories as any);
|
||||||
|
|
||||||
|
const optimisticMessages = [
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
createdAt: '2026-01-01T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const updateContext = vi.fn();
|
||||||
|
const content = {
|
||||||
|
updateHistoryCounter: 0,
|
||||||
|
historyKey: 'ws-1:doc-1:',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
docId: 'doc-1',
|
||||||
|
session: { sessionId: 'session-1' },
|
||||||
|
chatContextValue: {
|
||||||
|
messages: optimisticMessages,
|
||||||
|
status: 'loading',
|
||||||
|
},
|
||||||
|
updateContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (AIChatContent.prototype as any).updateHistory.call(content);
|
||||||
|
|
||||||
|
expect(updateContext).not.toHaveBeenCalled();
|
||||||
|
expect(content.chatContextValue.messages).toBe(optimisticMessages);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
// request counter to track the latest request
|
// request counter to track the latest request
|
||||||
private updateHistoryCounter = 0;
|
private updateHistoryCounter = 0;
|
||||||
|
|
||||||
|
private historyKey: string | undefined;
|
||||||
|
|
||||||
private lastScrollTop: number | undefined;
|
private lastScrollTop: number | undefined;
|
||||||
|
|
||||||
get messages() {
|
get messages() {
|
||||||
@@ -230,13 +232,19 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly updateHistory = async () => {
|
private async updateHistory() {
|
||||||
const currentRequest = ++this.updateHistoryCounter;
|
const currentRequest = ++this.updateHistoryCounter;
|
||||||
if (!AIProvider.histories) {
|
if (!AIProvider.histories) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = this.session?.sessionId;
|
const sessionId = this.session?.sessionId;
|
||||||
|
const nextHistoryKey = `${this.workspaceId}:${this.docId ?? ''}:${
|
||||||
|
sessionId ?? ''
|
||||||
|
}`;
|
||||||
|
const previousHistoryKey = this.historyKey;
|
||||||
|
const preserveCurrentMessages = previousHistoryKey === nextHistoryKey;
|
||||||
|
this.historyKey = nextHistoryKey;
|
||||||
const [histories, actions] = await Promise.all([
|
const [histories, actions] = await Promise.all([
|
||||||
sessionId
|
sessionId
|
||||||
? AIProvider.histories.chats(this.workspaceId, sessionId)
|
? AIProvider.histories.chats(this.workspaceId, sessionId)
|
||||||
@@ -251,9 +259,18 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages: HistoryMessage[] = this.chatContextValue.messages
|
if (
|
||||||
.slice()
|
!preserveCurrentMessages &&
|
||||||
.filter(isChatMessage);
|
(this.chatContextValue.status === 'loading' ||
|
||||||
|
this.chatContextValue.status === 'transmitting') &&
|
||||||
|
this.chatContextValue.messages.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: HistoryMessage[] = preserveCurrentMessages
|
||||||
|
? this.chatContextValue.messages.slice().filter(isChatMessage)
|
||||||
|
: [];
|
||||||
|
|
||||||
const chatActions = (actions || []) as ChatAction[];
|
const chatActions = (actions || []) as ChatAction[];
|
||||||
messages.push(...chatActions);
|
messages.push(...chatActions);
|
||||||
@@ -267,7 +284,7 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
private readonly updateActions = async () => {
|
private readonly updateActions = async () => {
|
||||||
if (!this.docId || !AIProvider.histories || !this.showActions) {
|
if (!this.docId || !AIProvider.histories || !this.showActions) {
|
||||||
@@ -337,6 +354,17 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override updated(changedProperties: PropertyValues) {
|
protected override updated(changedProperties: PropertyValues) {
|
||||||
|
const historySourceChanged =
|
||||||
|
(changedProperties.has('session') &&
|
||||||
|
changedProperties.get('session') !== undefined) ||
|
||||||
|
(changedProperties.has('docId') &&
|
||||||
|
changedProperties.get('docId') !== undefined) ||
|
||||||
|
(changedProperties.has('workspaceId') &&
|
||||||
|
changedProperties.get('workspaceId') !== undefined);
|
||||||
|
if (historySourceChanged) {
|
||||||
|
this.initChatContent().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
// restore pinned chat scroll position
|
// restore pinned chat scroll position
|
||||||
if (
|
if (
|
||||||
changedProperties.has('host') &&
|
changedProperties.has('host') &&
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor activeSessionId: string | undefined;
|
accessor activeSessionId: string | undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor showDraftTab = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onSelectTab!: (sessionId: string) => void;
|
accessor onSelectTab!: (sessionId: string) => void;
|
||||||
|
|
||||||
@@ -129,10 +132,11 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (!this.sessions.length) return html``;
|
if (!this.sessions.length && !this.showDraftTab) return html``;
|
||||||
return html`
|
return html`
|
||||||
<div class="ai-chat-tabs" data-testid="ai-chat-tabs">
|
<div class="ai-chat-tabs" data-testid="ai-chat-tabs">
|
||||||
<div class="tabs-scroll" @wheel=${this._handleWheel}>
|
<div class="tabs-scroll" @wheel=${this._handleWheel}>
|
||||||
|
${this.showDraftTab ? this._renderDraftTab() : null}
|
||||||
${repeat(
|
${repeat(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
session => session.sessionId,
|
session => session.sessionId,
|
||||||
@@ -177,6 +181,19 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _renderDraftTab() {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="tab"
|
||||||
|
data-active="true"
|
||||||
|
data-testid="ai-chat-draft-tab"
|
||||||
|
title=${DEFAULT_TAB_TITLE}
|
||||||
|
>
|
||||||
|
<span class="tab-title">${DEFAULT_TAB_TITLE}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly _handleSelect = (sessionId: string) => {
|
private readonly _handleSelect = (sessionId: string) => {
|
||||||
if (sessionId === this.activeSessionId) return;
|
if (sessionId === this.activeSessionId) return;
|
||||||
this.onSelectTab(sessionId);
|
this.onSelectTab(sessionId);
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onNewSession!: () => void;
|
accessor onNewSession!: () => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor canCreateNewSession = true;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onTogglePin!: () => Promise<void>;
|
accessor onTogglePin!: () => Promise<void>;
|
||||||
|
|
||||||
@@ -97,14 +100,16 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
const pinned = this.session?.pinned;
|
const pinned = this.session?.pinned;
|
||||||
return html`
|
return html`
|
||||||
<div class="ai-chat-toolbar">
|
<div class="ai-chat-toolbar">
|
||||||
<div
|
${this.canCreateNewSession
|
||||||
|
? html` <div
|
||||||
class="chat-toolbar-icon"
|
class="chat-toolbar-icon"
|
||||||
@click=${this.onPlusClick}
|
@click=${this.onPlusClick}
|
||||||
data-testid="ai-panel-new-chat"
|
data-testid="ai-panel-new-chat"
|
||||||
>
|
>
|
||||||
${PlusIcon()}
|
${PlusIcon()}
|
||||||
<affine-tooltip>New Chat</affine-tooltip>
|
<affine-tooltip>New Chat</affine-tooltip>
|
||||||
</div>
|
</div>`
|
||||||
|
: null}
|
||||||
<div
|
<div
|
||||||
class="chat-toolbar-icon"
|
class="chat-toolbar-icon"
|
||||||
@click=${this.onPinClick}
|
@click=${this.onPinClick}
|
||||||
@@ -202,6 +207,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
<ai-session-history
|
<ai-session-history
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
|
.docId=${this.docId}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.onSessionClick=${this.onSessionClick}
|
.onSessionClick=${this.onSessionClick}
|
||||||
.onSessionDelete=${this.onSessionDelete}
|
.onSessionDelete=${this.onSessionDelete}
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor workspaceId!: string;
|
accessor workspaceId!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor docId: string | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
@@ -179,6 +182,11 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
@state()
|
@state()
|
||||||
private accessor sessions: BlockSuitePresets.AIRecentSession[] | undefined;
|
private accessor sessions: BlockSuitePresets.AIRecentSession[] | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor currentDocSessions:
|
||||||
|
| BlockSuitePresets.AIRecentSession[]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private accessor loadingMore = false;
|
private accessor loadingMore = false;
|
||||||
|
|
||||||
@@ -256,6 +264,18 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
this.loadingMore = false;
|
this.loadingMore = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCurrentDocSessions() {
|
||||||
|
if (!this.docId) {
|
||||||
|
this.currentDocSessions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.currentDocSessions =
|
||||||
|
(await AIProvider.session?.getSessions(this.workspaceId, this.docId, {
|
||||||
|
action: false,
|
||||||
|
fork: false,
|
||||||
|
})) || [];
|
||||||
|
}
|
||||||
|
|
||||||
private readonly onScroll = () => {
|
private readonly onScroll = () => {
|
||||||
if (!this.hasMore || this.loadingMore) {
|
if (!this.hasMore || this.loadingMore) {
|
||||||
return;
|
return;
|
||||||
@@ -271,6 +291,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.selectedSessionId = this.session?.sessionId ?? undefined;
|
this.selectedSessionId = this.session?.sessionId ?? undefined;
|
||||||
|
this.getCurrentDocSessions().catch(console.error);
|
||||||
this.getRecentSessions().catch(console.error);
|
this.getRecentSessions().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +326,11 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
@click=${(e: MouseEvent) => {
|
@click=${(e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.selectedSessionId = session.sessionId;
|
this.selectedSessionId = session.sessionId;
|
||||||
|
if (session.docId) {
|
||||||
|
this.onDocClick(session.docId, session.sessionId);
|
||||||
|
} else {
|
||||||
this.onSessionClick(session.sessionId);
|
this.onSessionClick(session.sessionId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-selected=${this.selectedSessionId === session.sessionId}
|
aria-selected=${this.selectedSessionId === session.sessionId}
|
||||||
data-session-id=${session.sessionId}
|
data-session-id=${session.sessionId}
|
||||||
@@ -374,12 +399,23 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
return this.renderLoading();
|
return this.renderLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sessions.length === 0) {
|
const currentDocSessions = this.currentDocSessions ?? [];
|
||||||
|
const currentDocSessionIds = new Set(
|
||||||
|
currentDocSessions.map(session => session.sessionId)
|
||||||
|
);
|
||||||
|
const otherSessions = this.sessions.filter(
|
||||||
|
session =>
|
||||||
|
!currentDocSessionIds.has(session.sessionId) &&
|
||||||
|
(!this.docId || session.docId !== this.docId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentDocSessions.length === 0 && otherSessions.length === 0) {
|
||||||
return this.renderEmpty();
|
return this.renderEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedSessions = this.groupSessionsByTime(this.sessions);
|
const groupedSessions = this.groupSessionsByTime(otherSessions);
|
||||||
return html`
|
return html`
|
||||||
|
${this.renderSessionGroup('Current document', currentDocSessions)}
|
||||||
${this.renderSessionGroup('Today', groupedSessions.today)}
|
${this.renderSessionGroup('Today', groupedSessions.today)}
|
||||||
${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)}
|
${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)}
|
||||||
${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)}
|
${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ConfigureAIChatToolbarOptions = {
|
|||||||
docDisplayConfig: DocDisplayConfig;
|
docDisplayConfig: DocDisplayConfig;
|
||||||
notificationService: NotificationService;
|
notificationService: NotificationService;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
|
canCreateNewSession?: boolean;
|
||||||
onTogglePin: () => Promise<void>;
|
onTogglePin: () => Promise<void>;
|
||||||
onOpenSession: (sessionId: string) => void;
|
onOpenSession: (sessionId: string) => void;
|
||||||
onOpenDoc: (docId: string, sessionId: string) => void;
|
onOpenDoc: (docId: string, sessionId: string) => void;
|
||||||
@@ -36,6 +37,7 @@ export function configureAIChatToolbar(
|
|||||||
tool.docDisplayConfig = options.docDisplayConfig;
|
tool.docDisplayConfig = options.docDisplayConfig;
|
||||||
tool.notificationService = options.notificationService;
|
tool.notificationService = options.notificationService;
|
||||||
tool.onNewSession = options.onNewSession;
|
tool.onNewSession = options.onNewSession;
|
||||||
|
tool.canCreateNewSession = options.canCreateNewSession ?? true;
|
||||||
tool.onTogglePin = options.onTogglePin;
|
tool.onTogglePin = options.onTogglePin;
|
||||||
tool.onOpenSession = options.onOpenSession;
|
tool.onOpenSession = options.onOpenSession;
|
||||||
tool.onOpenDoc = options.onOpenDoc;
|
tool.onOpenDoc = options.onOpenDoc;
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { type CopilotChatHistoryFragment } from '@affine/graphql';
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canCreateNewDocPanelSession,
|
||||||
|
filterDocPanelTabs,
|
||||||
getChatContentKey,
|
getChatContentKey,
|
||||||
|
hasSessionMessages,
|
||||||
|
isSessionAvailableInDocPanel,
|
||||||
resolveInitialSession,
|
resolveInitialSession,
|
||||||
type SessionService,
|
type SessionService,
|
||||||
shouldResetChatPanelOnUserInfoChange,
|
shouldResetChatPanelOnUserInfoChange,
|
||||||
@@ -89,6 +93,22 @@ describe('getChatContentKey', () => {
|
|||||||
},
|
},
|
||||||
expected: 'session-2',
|
expected: 'session-2',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'keeps generating draft session on the doc key',
|
||||||
|
input: {
|
||||||
|
docId: 'doc-1',
|
||||||
|
hasPinned: false,
|
||||||
|
isGenerating: true,
|
||||||
|
previousSessionDocId: 'doc-1',
|
||||||
|
previousSessionId: 'session-1',
|
||||||
|
session: {
|
||||||
|
sessionId: 'session-2',
|
||||||
|
docId: 'doc-1',
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: 'doc-1',
|
||||||
|
},
|
||||||
] satisfies {
|
] satisfies {
|
||||||
name: string;
|
name: string;
|
||||||
input: Parameters<typeof getChatContentKey>[0];
|
input: Parameters<typeof getChatContentKey>[0];
|
||||||
@@ -145,6 +165,55 @@ describe('shouldResetChatPanelOnUserInfoChange', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('doc panel tabs', () => {
|
||||||
|
const sessions = [
|
||||||
|
{ sessionId: 'current-doc-session', docId: 'doc-1' },
|
||||||
|
{ sessionId: 'workspace-session', docId: null },
|
||||||
|
{ sessionId: 'other-doc-session', docId: 'doc-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('allows only current doc or workspace sessions', () => {
|
||||||
|
expect(filterDocPanelTabs(sessions, 'doc-1')).toEqual([
|
||||||
|
sessions[0],
|
||||||
|
sessions[1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects other document sessions', () => {
|
||||||
|
expect(isSessionAvailableInDocPanel(sessions[2], 'doc-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('new session guard', () => {
|
||||||
|
test('allows a new session only after the current chat has messages', () => {
|
||||||
|
expect(
|
||||||
|
canCreateNewDocPanelSession({
|
||||||
|
hasContextMessages: false,
|
||||||
|
session: { messages: [] },
|
||||||
|
status: 'idle',
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canCreateNewDocPanelSession({
|
||||||
|
hasContextMessages: true,
|
||||||
|
session: { messages: [] },
|
||||||
|
status: 'idle',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(hasSessionMessages({ messages: [{ id: 'message-1' }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not allow a new session while generating', () => {
|
||||||
|
expect(
|
||||||
|
canCreateNewDocPanelSession({
|
||||||
|
hasContextMessages: true,
|
||||||
|
session: null,
|
||||||
|
status: 'loading',
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('returns undefined without session service or doc', async () => {
|
test('returns undefined without session service or doc', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
resolveInitialSession({ sessionService: null, doc, workbench: null })
|
resolveInitialSession({ sessionService: null, doc, workbench: null })
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ interface ChatContentKeySession {
|
|||||||
messages?: readonly unknown[] | null;
|
messages?: readonly unknown[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabSession = {
|
||||||
|
sessionId: string;
|
||||||
|
docId?: string | null;
|
||||||
|
messages?: readonly unknown[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const shouldResetChatPanelOnUserInfoChange = ({
|
export const shouldResetChatPanelOnUserInfoChange = ({
|
||||||
previousUserId,
|
previousUserId,
|
||||||
nextUserId,
|
nextUserId,
|
||||||
@@ -62,12 +68,14 @@ export const shouldResetChatPanelOnUserInfoChange = ({
|
|||||||
export const getChatContentKey = ({
|
export const getChatContentKey = ({
|
||||||
docId,
|
docId,
|
||||||
hasPinned,
|
hasPinned,
|
||||||
|
isGenerating,
|
||||||
previousSessionDocId,
|
previousSessionDocId,
|
||||||
previousSessionId,
|
previousSessionId,
|
||||||
session,
|
session,
|
||||||
}: {
|
}: {
|
||||||
docId?: string | null;
|
docId?: string | null;
|
||||||
hasPinned: boolean;
|
hasPinned: boolean;
|
||||||
|
isGenerating?: boolean;
|
||||||
previousSessionDocId?: string | null;
|
previousSessionDocId?: string | null;
|
||||||
previousSessionId?: string | null;
|
previousSessionId?: string | null;
|
||||||
session?: ChatContentKeySession | null;
|
session?: ChatContentKeySession | null;
|
||||||
@@ -80,6 +88,7 @@ export const getChatContentKey = ({
|
|||||||
|
|
||||||
const sessionDocId = session.docId ?? docId ?? null;
|
const sessionDocId = session.docId ?? docId ?? null;
|
||||||
const hasSessionHistory = !!session.messages?.length;
|
const hasSessionHistory = !!session.messages?.length;
|
||||||
|
const shouldPreserveTransientMessages = isGenerating && !hasSessionHistory;
|
||||||
const sessionSwitchedWithinDoc = !!(
|
const sessionSwitchedWithinDoc = !!(
|
||||||
previousSessionId &&
|
previousSessionId &&
|
||||||
previousSessionId !== sessionId &&
|
previousSessionId !== sessionId &&
|
||||||
@@ -89,11 +98,51 @@ export const getChatContentKey = ({
|
|||||||
sessionDocId === docId
|
sessionDocId === docId
|
||||||
);
|
);
|
||||||
|
|
||||||
return hasPinned || hasSessionHistory || sessionSwitchedWithinDoc
|
return hasPinned ||
|
||||||
|
hasSessionHistory ||
|
||||||
|
(sessionSwitchedWithinDoc && !shouldPreserveTransientMessages)
|
||||||
? sessionId
|
? sessionId
|
||||||
: fallbackKey;
|
: fallbackKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isSessionAvailableInDocPanel = (
|
||||||
|
session: TabSession,
|
||||||
|
docId?: string | null
|
||||||
|
) => {
|
||||||
|
return !session.docId || session.docId === docId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterDocPanelTabs = <T extends TabSession>(
|
||||||
|
sessions: T[],
|
||||||
|
docId?: string | null
|
||||||
|
) => {
|
||||||
|
return sessions.filter(session =>
|
||||||
|
isSessionAvailableInDocPanel(session, docId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasSessionMessages = (
|
||||||
|
session?: Pick<TabSession, 'messages'> | null
|
||||||
|
) => {
|
||||||
|
return !!session?.messages?.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canCreateNewDocPanelSession = ({
|
||||||
|
hasContextMessages,
|
||||||
|
session,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
hasContextMessages: boolean;
|
||||||
|
session?: Pick<TabSession, 'messages'> | null;
|
||||||
|
status?: string | null;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
(hasContextMessages || hasSessionMessages(session)) &&
|
||||||
|
status !== 'loading' &&
|
||||||
|
status !== 'transmitting'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getSessionIdFromUrl = (workbench?: WorkbenchLike | null) => {
|
export const getSessionIdFromUrl = (workbench?: WorkbenchLike | null) => {
|
||||||
if (!workbench) {
|
if (!workbench) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ import {
|
|||||||
} from '../../chat-panel-utils';
|
} from '../../chat-panel-utils';
|
||||||
import * as styles from './chat.css';
|
import * as styles from './chat.css';
|
||||||
import {
|
import {
|
||||||
|
canCreateNewDocPanelSession,
|
||||||
|
filterDocPanelTabs,
|
||||||
getChatContentKey,
|
getChatContentKey,
|
||||||
|
isSessionAvailableInDocPanel,
|
||||||
resolveInitialSession,
|
resolveInitialSession,
|
||||||
shouldResetChatPanelOnUserInfoChange,
|
shouldResetChatPanelOnUserInfoChange,
|
||||||
type WorkbenchLike,
|
type WorkbenchLike,
|
||||||
@@ -108,6 +111,14 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
const prevSessionDocIdRef = useRef<string | null>(null);
|
const prevSessionDocIdRef = useRef<string | null>(null);
|
||||||
const lastDocIdRef = useRef<string | null>(null);
|
const lastDocIdRef = useRef<string | null>(null);
|
||||||
const sessionLoadSeqRef = useRef(0);
|
const sessionLoadSeqRef = useRef(0);
|
||||||
|
const creatingSessionRef = useRef<{
|
||||||
|
docId: string;
|
||||||
|
promise: Promise<CopilotChatHistoryFragment | undefined>;
|
||||||
|
} | null>(null);
|
||||||
|
const creatingFreshSessionRef = useRef<{
|
||||||
|
docId: string;
|
||||||
|
promise: Promise<void>;
|
||||||
|
} | null>(null);
|
||||||
const userIdRef = useRef<string | null | undefined>(undefined);
|
const userIdRef = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
const doc = editor?.doc;
|
const doc = editor?.doc;
|
||||||
@@ -117,6 +128,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
const [sessionServiceReady, setSessionServiceReady] = useState(
|
const [sessionServiceReady, setSessionServiceReady] = useState(
|
||||||
() => !!AIProvider.session
|
() => !!AIProvider.session
|
||||||
);
|
);
|
||||||
|
const [hasContextMessages, setHasContextMessages] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionServiceReady) return;
|
if (sessionServiceReady) return;
|
||||||
@@ -142,6 +154,15 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
|
|
||||||
const { openTabs, setOpenTabs } =
|
const { openTabs, setOpenTabs } =
|
||||||
useAIChatOpenTabs<CopilotChatHistoryFragment>(loadSession);
|
useAIChatOpenTabs<CopilotChatHistoryFragment>(loadSession);
|
||||||
|
const visibleOpenTabs = useMemo(
|
||||||
|
() => filterDocPanelTabs(openTabs, doc?.id),
|
||||||
|
[doc?.id, openTabs]
|
||||||
|
);
|
||||||
|
const canCreateNewSession = canCreateNewDocPanelSession({
|
||||||
|
hasContextMessages,
|
||||||
|
session,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
|
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
|
||||||
return {
|
return {
|
||||||
@@ -200,18 +221,32 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
if (session || !AIProvider.session || !doc) {
|
if (session || !AIProvider.session || !doc) {
|
||||||
return session ?? undefined;
|
return session ?? undefined;
|
||||||
}
|
}
|
||||||
|
if (creatingSessionRef.current?.docId === doc.id) {
|
||||||
|
return creatingSessionRef.current.promise;
|
||||||
|
}
|
||||||
const requestSeq = ++sessionLoadSeqRef.current;
|
const requestSeq = ++sessionLoadSeqRef.current;
|
||||||
const nextSession = await AIProvider.session.createSessionWithHistory({
|
let promise: Promise<CopilotChatHistoryFragment | undefined>;
|
||||||
|
promise = AIProvider.session
|
||||||
|
.createSessionWithHistory({
|
||||||
docId: doc.id,
|
docId: doc.id,
|
||||||
workspaceId: doc.workspace.id,
|
workspaceId: doc.workspace.id,
|
||||||
promptName: 'Chat With AFFiNE AI',
|
promptName: 'Chat With AFFiNE AI',
|
||||||
reuseLatestChat: false,
|
reuseLatestChat: false,
|
||||||
...options,
|
...options,
|
||||||
});
|
})
|
||||||
|
.then(nextSession => {
|
||||||
if (requestSeq !== sessionLoadSeqRef.current) return undefined;
|
if (requestSeq !== sessionLoadSeqRef.current) return undefined;
|
||||||
setSession(nextSession ?? null);
|
setSession(nextSession ?? null);
|
||||||
setHasPinned(!!nextSession?.pinned);
|
setHasPinned(!!nextSession?.pinned);
|
||||||
return nextSession ?? undefined;
|
return nextSession ?? undefined;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (creatingSessionRef.current?.promise === promise) {
|
||||||
|
creatingSessionRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
creatingSessionRef.current = { docId: doc.id, promise };
|
||||||
|
return promise;
|
||||||
},
|
},
|
||||||
[doc, session]
|
[doc, session]
|
||||||
);
|
);
|
||||||
@@ -236,29 +271,44 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const newSession = useCallback(async () => {
|
const newSession = useCallback(async () => {
|
||||||
|
if (!canCreateNewSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (doc && creatingFreshSessionRef.current?.docId === doc.id) {
|
||||||
|
return creatingFreshSessionRef.current.promise;
|
||||||
|
}
|
||||||
resetPanel();
|
resetPanel();
|
||||||
const requestSeq = sessionLoadSeqRef.current;
|
const requestSeq = sessionLoadSeqRef.current;
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
setHasContextMessages(false);
|
||||||
|
|
||||||
if (!AIProvider.session || !doc) {
|
if (!AIProvider.session || !doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let promise: Promise<void>;
|
||||||
const nextSession = await AIProvider.session.createSessionWithHistory({
|
promise = AIProvider.session
|
||||||
|
.createSessionWithHistory({
|
||||||
docId: doc.id,
|
docId: doc.id,
|
||||||
workspaceId: doc.workspace.id,
|
workspaceId: doc.workspace.id,
|
||||||
promptName: 'Chat With AFFiNE AI',
|
promptName: 'Chat With AFFiNE AI',
|
||||||
reuseLatestChat: false,
|
reuseLatestChat: false,
|
||||||
});
|
})
|
||||||
|
.then(nextSession => {
|
||||||
if (requestSeq === sessionLoadSeqRef.current) {
|
if (requestSeq === sessionLoadSeqRef.current) {
|
||||||
setSession(nextSession ?? null);
|
setSession(nextSession ?? null);
|
||||||
setHasPinned(!!nextSession?.pinned);
|
setHasPinned(!!nextSession?.pinned);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})
|
||||||
console.error(error);
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
if (creatingFreshSessionRef.current?.promise === promise) {
|
||||||
|
creatingFreshSessionRef.current = null;
|
||||||
}
|
}
|
||||||
}, [doc, resetPanel]);
|
});
|
||||||
|
creatingFreshSessionRef.current = { docId: doc.id, promise };
|
||||||
|
return promise;
|
||||||
|
}, [canCreateNewSession, doc, resetPanel]);
|
||||||
|
|
||||||
const openSession = useCallback(
|
const openSession = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string) => {
|
||||||
@@ -277,13 +327,20 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
|
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isSessionAvailableInDocPanel(nextSession, doc.id)) {
|
||||||
|
setOpenTabs([]);
|
||||||
|
workbench.open(`/${nextSession.docId}?sessionId=${sessionId}`, {
|
||||||
|
at: 'active',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setHasPinned(!!nextSession.pinned);
|
setHasPinned(!!nextSession.pinned);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[doc, session?.sessionId, setOpenTabs]
|
[doc, session?.sessionId, setOpenTabs, workbench]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDoc = useCallback(
|
const openDoc = useCallback(
|
||||||
@@ -304,9 +361,17 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
workbench.open(`/${docId}`, { at: 'active' });
|
workbench.open(`/${docId}`, { at: 'active' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setOpenTabs([]);
|
||||||
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
|
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
|
||||||
},
|
},
|
||||||
[doc, openSession, session?.pinned, session?.sessionId, workbench]
|
[
|
||||||
|
doc,
|
||||||
|
openSession,
|
||||||
|
session?.pinned,
|
||||||
|
session?.sessionId,
|
||||||
|
setOpenTabs,
|
||||||
|
workbench,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSession = useMemo(
|
const deleteSession = useMemo(
|
||||||
@@ -325,10 +390,12 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
isActiveSession: sessionToDelete =>
|
isActiveSession: sessionToDelete =>
|
||||||
sessionToDelete.sessionId === session?.sessionId,
|
sessionToDelete.sessionId === session?.sessionId,
|
||||||
onActiveSessionDeleted: () => {
|
onActiveSessionDeleted: () => {
|
||||||
newSession().catch(console.error);
|
resetPanel();
|
||||||
|
setSession(null);
|
||||||
|
setHasContextMessages(false);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[newSession, notificationService, session?.sessionId, t]
|
[notificationService, resetPanel, session?.sessionId, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeTab = useCallback(
|
const closeTab = useCallback(
|
||||||
@@ -338,17 +405,20 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
|
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
|
||||||
if (idx === -1) return prev;
|
if (idx === -1) return prev;
|
||||||
const next = prev.filter(tab => tab.sessionId !== sessionId);
|
const next = prev.filter(tab => tab.sessionId !== sessionId);
|
||||||
fallback = next[idx] ?? next[idx - 1] ?? next[0];
|
const visibleNext = filterDocPanelTabs(next, doc?.id);
|
||||||
|
fallback = visibleNext[idx] ?? visibleNext[idx - 1] ?? visibleNext[0];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (session?.sessionId !== sessionId) return;
|
if (session?.sessionId !== sessionId) return;
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
openSession(fallback.sessionId).catch(console.error);
|
openSession(fallback.sessionId).catch(console.error);
|
||||||
} else {
|
} else {
|
||||||
newSession().catch(console.error);
|
resetPanel();
|
||||||
|
setSession(null);
|
||||||
|
setHasContextMessages(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[newSession, openSession, session?.sessionId, setOpenTabs]
|
[doc?.id, openSession, resetPanel, session?.sessionId, setOpenTabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const togglePin = useCallback(async () => {
|
const togglePin = useCallback(async () => {
|
||||||
@@ -387,7 +457,12 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
|
|
||||||
const onContextChange = useCallback(
|
const onContextChange = useCallback(
|
||||||
(context: Partial<ChatContextValue>) => {
|
(context: Partial<ChatContextValue>) => {
|
||||||
setStatus(context.status ?? 'idle');
|
if (context.status) {
|
||||||
|
setStatus(context.status);
|
||||||
|
}
|
||||||
|
if (context.messages) {
|
||||||
|
setHasContextMessages(context.messages.length > 0);
|
||||||
|
}
|
||||||
if (context.status === 'success') {
|
if (context.status === 'success') {
|
||||||
rebindSession().catch(console.error);
|
rebindSession().catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -468,6 +543,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
!session?.pinned
|
!session?.pinned
|
||||||
) {
|
) {
|
||||||
resetPanel();
|
resetPanel();
|
||||||
|
setHasContextMessages(false);
|
||||||
}
|
}
|
||||||
lastDocIdRef.current = docId;
|
lastDocIdRef.current = docId;
|
||||||
}, [doc?.id, resetPanel, session?.pinned]);
|
}, [doc?.id, resetPanel, session?.pinned]);
|
||||||
@@ -490,6 +566,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
const contentKey = getChatContentKey({
|
const contentKey = getChatContentKey({
|
||||||
docId: doc?.id,
|
docId: doc?.id,
|
||||||
hasPinned,
|
hasPinned,
|
||||||
|
isGenerating: status === 'loading' || status === 'transmitting',
|
||||||
previousSessionDocId: prevSessionDocIdRef.current,
|
previousSessionDocId: prevSessionDocIdRef.current,
|
||||||
previousSessionId: prevSessionIdRef.current,
|
previousSessionId: prevSessionIdRef.current,
|
||||||
session,
|
session,
|
||||||
@@ -597,6 +674,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
workspaceId: doc.workspace.id,
|
workspaceId: doc.workspace.id,
|
||||||
docId: doc.id,
|
docId: doc.id,
|
||||||
status,
|
status,
|
||||||
|
canCreateNewSession,
|
||||||
docDisplayConfig,
|
docDisplayConfig,
|
||||||
notificationService,
|
notificationService,
|
||||||
onNewSession: () => {
|
onNewSession: () => {
|
||||||
@@ -620,6 +698,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
chatToolbar,
|
chatToolbar,
|
||||||
|
canCreateNewSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
doc,
|
doc,
|
||||||
docDisplayConfig,
|
docDisplayConfig,
|
||||||
@@ -647,15 +726,17 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
chatTabsContainerRef.current.append(tabs);
|
chatTabsContainerRef.current.append(tabs);
|
||||||
setChatTabs(tabs);
|
setChatTabs(tabs);
|
||||||
}
|
}
|
||||||
tabs.sessions = openTabs;
|
tabs.sessions = visibleOpenTabs;
|
||||||
tabs.activeSessionId = session?.sessionId;
|
tabs.activeSessionId = session?.sessionId;
|
||||||
|
tabs.showDraftTab =
|
||||||
|
visibleOpenTabs.length === 0 && !session?.sessionId && !!doc;
|
||||||
tabs.onSelectTab = (sessionId: string) => {
|
tabs.onSelectTab = (sessionId: string) => {
|
||||||
openSession(sessionId).catch(console.error);
|
openSession(sessionId).catch(console.error);
|
||||||
};
|
};
|
||||||
tabs.onCloseTab = (sessionId: string) => {
|
tabs.onCloseTab = (sessionId: string) => {
|
||||||
closeTab(sessionId);
|
closeTab(sessionId);
|
||||||
};
|
};
|
||||||
}, [chatTabs, closeTab, doc, openSession, openTabs, session]);
|
}, [chatTabs, closeTab, doc, openSession, session, visibleOpenTabs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor?.host || !chatContent) {
|
if (!editor?.host || !chatContent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user