Skip to content

Commit 8df587c

Browse files
committed
fix(chat): use full workspace context for root AI chats
1 parent 694b5e4 commit 8df587c

6 files changed

Lines changed: 192 additions & 57 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { View, ViewLayout } from '@/application/types';
2+
import { buildInitialAIChatSettings, isWorkspaceRootView } from '@/components/ai-chat/chat-settings';
3+
4+
function view(overrides: Partial<View>): View {
5+
return {
6+
view_id: 'view-id',
7+
name: 'View',
8+
icon: null,
9+
layout: ViewLayout.Document,
10+
extra: { is_space: false },
11+
children: [],
12+
is_published: false,
13+
is_private: false,
14+
...overrides,
15+
};
16+
}
17+
18+
describe('AI chat initial settings', () => {
19+
it('uses full workspace context for chats created under a workspace root', () => {
20+
const parent = view({
21+
view_id: 'space-id',
22+
extra: { is_space: true },
23+
});
24+
25+
expect(isWorkspaceRootView(parent)).toBe(true);
26+
expect(
27+
buildInitialAIChatSettings({
28+
parent,
29+
query: 'appflowy',
30+
sourceIds: ['doc-1', 'doc-2'],
31+
})
32+
).toEqual({
33+
full_workspace: true,
34+
rag_ids: [],
35+
metadata: { initial_prompt: 'appflowy' },
36+
});
37+
});
38+
39+
it('keeps explicit source context for chats created under a page', () => {
40+
expect(
41+
buildInitialAIChatSettings({
42+
parent: view({ view_id: 'page-id' }),
43+
sourceIds: ['doc-1', 'doc-1', '', 'doc-2'],
44+
})
45+
).toEqual({
46+
full_workspace: false,
47+
rag_ids: ['doc-1', 'doc-2'],
48+
});
49+
});
50+
51+
it('preserves the initial prompt without adding source context for non-root chats', () => {
52+
expect(
53+
buildInitialAIChatSettings({
54+
parent: view({ view_id: 'page-id' }),
55+
query: 'summarize this',
56+
})
57+
).toEqual({
58+
metadata: { initial_prompt: 'summarize this' },
59+
});
60+
});
61+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { View } from '@/application/types';
2+
import type { ChatSettings } from '@/components/chat/types';
3+
4+
export function isWorkspaceRootView(view: View | undefined): boolean {
5+
return Boolean(view?.extra?.is_space);
6+
}
7+
8+
export function buildInitialAIChatSettings({
9+
parent,
10+
query,
11+
sourceIds,
12+
}: {
13+
parent?: View;
14+
query?: string;
15+
sourceIds?: string[];
16+
}): Partial<Pick<ChatSettings, 'rag_ids' | 'metadata' | 'full_workspace'>> {
17+
const metadata = query ? { initial_prompt: query } : undefined;
18+
19+
if (isWorkspaceRootView(parent)) {
20+
return {
21+
full_workspace: true,
22+
rag_ids: [],
23+
...(metadata ? { metadata } : {}),
24+
};
25+
}
26+
27+
const uniqueSourceIds = Array.from(new Set(sourceIds || [])).filter(Boolean);
28+
29+
return {
30+
...(uniqueSourceIds.length > 0 ? { full_workspace: false, rag_ids: uniqueSourceIds } : {}),
31+
...(metadata ? { metadata } : {}),
32+
};
33+
}

src/components/app/search/Search.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg';
77
import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg';
88
import { notify } from '@/components/_shared/notify';
99
import { findAncestors } from '@/components/_shared/outline/utils';
10+
import { buildInitialAIChatSettings } from '@/components/ai-chat/chat-settings';
1011
import {
1112
useAIEnabled,
1213
useAppOperations,
@@ -71,10 +72,10 @@ export function Search() {
7172
name: query || t('chat.newChat', { defaultValue: 'New chat' }),
7273
prev_view_id: parent.children?.[parent.children.length - 1]?.view_id,
7374
});
74-
const uniqueSourceIds = Array.from(new Set(sourceIds || [])).filter(Boolean);
75+
const initialSettings = buildInitialAIChatSettings({ parent, query, sourceIds });
7576
let settingsError: unknown;
7677

77-
if (uniqueSourceIds.length > 0 || query) {
78+
if (Object.keys(initialSettings).length > 0) {
7879
try {
7980
const [{ ChatRequest }, { getAxiosInstance }] = await Promise.all([
8081
import('@/components/chat/request'),
@@ -88,10 +89,7 @@ export function Search() {
8889

8990
const request = new ChatRequest(currentWorkspaceId, created.view_id, axiosInstance);
9091

91-
await request.updateChatSettings({
92-
...(uniqueSourceIds.length > 0 ? { rag_ids: uniqueSourceIds } : {}),
93-
...(query ? { metadata: { initial_prompt: query } } : {}),
94-
});
92+
await request.updateChatSettings(initialSettings);
9593
} catch (error) {
9694
settingsError = error;
9795
}

src/components/app/view-actions/AddPageActions.tsx

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import { toast } from 'sonner';
55
import { View, ViewLayout } from '@/application/types';
66
import { ReactComponent as UploadIcon } from '@/assets/icons/upload.svg';
77
import { ViewIcon } from '@/components/_shared/view-icon';
8-
import { useAIEnabled, useAppOperations, useOpenPageModal, useToView } from '@/components/app/app.hooks';
8+
import { buildInitialAIChatSettings } from '@/components/ai-chat/chat-settings';
9+
import {
10+
useAIEnabled,
11+
useAppOperations,
12+
useCurrentWorkspaceId,
13+
useOpenPageModal,
14+
useToView,
15+
} from '@/components/app/app.hooks';
916
import { DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu';
1017
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
1118

@@ -15,31 +22,61 @@ function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: (
1522
const openPageModal = useOpenPageModal();
1623
const toView = useToView();
1724
const aiEnabled = useAIEnabled();
25+
const currentWorkspaceId = useCurrentWorkspaceId();
1826
const lastChildViewId = view.children?.[view.children.length - 1]?.view_id;
1927

2028
const handleAddPage = useCallback(
2129
async (layout: ViewLayout, name?: string) => {
2230
if (!addPage) return;
2331
if (layout === ViewLayout.AIChat && !aiEnabled) return;
24-
toast.loading(t('document.creating'));
32+
const loadingToastId = toast.loading(t('document.creating'));
2533
try {
2634
// Append after the last child so the new page appears at the bottom.
2735
// When prev_view_id is omitted the backend prepends (inserts at index 0).
2836
const response = await addPage(view.view_id, { layout, name, prev_view_id: lastChildViewId });
2937

38+
if (layout === ViewLayout.AIChat && currentWorkspaceId) {
39+
const initialSettings = buildInitialAIChatSettings({ parent: view });
40+
41+
if (Object.keys(initialSettings).length > 0) {
42+
try {
43+
const [{ ChatRequest }, { getAxiosInstance }] = await Promise.all([
44+
import('@/components/chat/request'),
45+
import('@/application/services/js-services/http'),
46+
]);
47+
const axiosInstance = getAxiosInstance();
48+
49+
if (!axiosInstance) {
50+
throw new Error('Missing axios instance');
51+
}
52+
53+
const request = new ChatRequest(currentWorkspaceId, response.view_id, axiosInstance);
54+
55+
await request.updateChatSettings(initialSettings);
56+
} catch {
57+
toast.error(
58+
t('search.updateAIChatSettingsFailed', {
59+
defaultValue: 'AI chat was created, but the context could not be attached',
60+
})
61+
);
62+
}
63+
}
64+
}
65+
3066
if (layout === ViewLayout.Document) {
3167
void openPageModal?.(response.view_id);
3268
} else {
3369
void toView(response.view_id);
3470
}
3571

36-
toast.dismiss();
72+
toast.dismiss(loadingToastId);
3773
// eslint-disable-next-line
3874
} catch (e: any) {
75+
toast.dismiss(loadingToastId);
3976
toast.error(e.message);
4077
}
4178
},
42-
[addPage, aiEnabled, openPageModal, t, toView, view.view_id, lastChildViewId]
79+
[addPage, aiEnabled, currentWorkspaceId, openPageModal, t, toView, view, lastChildViewId]
4380
);
4481

4582
const actions: {
@@ -80,16 +117,18 @@ function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: (
80117
void handleAddPage(ViewLayout.Calendar, t('document.plugins.database.newDatabase'));
81118
},
82119
},
83-
...(aiEnabled ? [
84-
{
85-
label: t('chat.newChat'),
86-
icon: <ViewIcon layout={ViewLayout.AIChat} size={'small'} />,
87-
testId: 'add-ai-chat-button',
88-
onSelect: () => {
89-
void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName'));
90-
},
91-
},
92-
] : []),
120+
...(aiEnabled
121+
? [
122+
{
123+
label: t('chat.newChat'),
124+
icon: <ViewIcon layout={ViewLayout.AIChat} size={'small'} />,
125+
testId: 'add-ai-chat-button',
126+
onSelect: () => {
127+
void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName'));
128+
},
129+
},
130+
]
131+
: []),
93132
{
94133
label: t('chart.menuName'),
95134
icon: <ViewIcon layout={ViewLayout.Chart} size={'small'} />,

src/components/chat/components/chat-input/related-views/index.tsx

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,44 +11,58 @@ import { useCheckboxTree } from '@/components/chat/hooks/use-checkbox-tree';
1111
import { MESSAGE_VARIANTS } from '@/components/chat/lib/animations';
1212
import { searchViews } from '@/components/chat/lib/views';
1313
import { useMessagesHandlerContext } from '@/components/chat/provider/messages-handler-provider';
14-
import { View } from '@/components/chat/types';
14+
import { View, ViewLayout } from '@/components/chat/types';
1515
import { Button } from '@/components/ui/button';
1616
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
1717
import { Separator } from '@/components/ui/separator';
1818

1919
import { Spaces } from './spaces';
2020

21+
function collectSelectableViewIds(views: View[]): string[] {
22+
const ids: string[] = [];
23+
const stack = [...views];
2124

25+
while (stack.length > 0) {
26+
const view = stack.pop();
2227

23-
export function RelatedViews() {
28+
if (!view || view.layout === ViewLayout.AIChat) continue;
29+
30+
ids.push(view.view_id);
31+
stack.push(...view.children);
32+
}
33+
34+
return ids;
35+
}
2436

37+
export function RelatedViews() {
2538
const [searchValue, setSearchValue] = useState('');
2639
const [open, setOpen] = useState(false);
2740

2841
const { chatSettings, updateChatSettings } = useMessagesHandlerContext();
2942

30-
const viewIds = useMemo(() => {
31-
return chatSettings?.rag_ids || [];
32-
}, [chatSettings]);
33-
34-
const {
35-
fetchViews,
36-
viewsLoading,
37-
} = useViewLoader();
43+
const { fetchViews, viewsLoading } = useViewLoader();
3844

3945
const [folder, setFolder] = useState<View | null>(null);
4046

4147
useEffect(() => {
42-
void (async() => {
48+
void (async () => {
4349
const data = await fetchViews();
4450

45-
if(!data) return;
51+
if (!data) return;
4652
setFolder(data);
4753
})();
4854
}, [fetchViews]);
4955

56+
const viewIds = useMemo(() => {
57+
if (chatSettings?.full_workspace && folder) {
58+
return collectSelectableViewIds(folder.children || []);
59+
}
60+
61+
return chatSettings?.rag_ids || [];
62+
}, [chatSettings, folder]);
63+
5064
const filteredSpaces = useMemo(() => {
51-
const spaces = folder?.children.filter(view => view.extra?.is_space);
65+
const spaces = folder?.children.filter((view) => view.extra?.is_space);
5266

5367
return searchViews(spaces || [], searchValue);
5468
}, [folder, searchValue]);
@@ -57,19 +71,15 @@ export function RelatedViews() {
5771
return folder?.children || [];
5872
}, [folder]);
5973

60-
const {
61-
getSelected,
62-
getCheckStatus,
63-
toggleNode,
64-
getInitialExpand,
65-
} = useCheckboxTree(viewIds, views);
74+
const { getSelected, getCheckStatus, toggleNode, getInitialExpand } = useCheckboxTree(viewIds, views);
6675

6776
const length = getSelected().length;
6877

6978
const handleToggle = useMemo(() => {
70-
return debounce(async(ids: string[]) => {
79+
return debounce(async (ids: string[]) => {
7180
await updateChatSettings({
7281
rag_ids: ids,
82+
full_workspace: false,
7383
});
7484
}, 500);
7585
}, [updateChatSettings]);
@@ -86,37 +96,30 @@ export function RelatedViews() {
8696
>
8797
<DocIcon className='h-5 w-5 text-icon-secondary' />
8898
{length}
89-
{viewsLoading ? <LoadingDots size={12} /> : <ChevronDown className='w-3 h-5' />}
90-
99+
{viewsLoading ? <LoadingDots size={12} /> : <ChevronDown className='h-5 w-3' />}
91100
</Button>
92101
</PopoverTrigger>
93102
<PopoverContent asChild>
94103
<motion.div
95104
variants={MESSAGE_VARIANTS.getSelectorVariants()}
96-
initial="hidden"
97-
animate={open ? "visible" : "exit"}
98-
className={'h-fit min-h-[200px] max-h-[360px] w-[300px] flex flex-col'}
105+
initial='hidden'
106+
animate={open ? 'visible' : 'exit'}
107+
className={'flex h-fit max-h-[360px] min-h-[200px] w-[300px] flex-col'}
99108
data-testid='chat-related-views-popover'
100109
>
101-
<SearchInput
102-
className='m-2'
103-
value={searchValue}
104-
onChange={setSearchValue}
105-
/>
110+
<SearchInput className='m-2' value={searchValue} onChange={setSearchValue} />
106111
<Separator />
107-
<div className={'overflow-x-hidden overflow-y-auto flex-1 appflowy-scrollbar p-2'}>
112+
<div className={'appflowy-scrollbar flex-1 overflow-y-auto overflow-x-hidden p-2'}>
108113
<Spaces
109114
getInitialExpand={getInitialExpand}
110115
spaces={filteredSpaces}
111116
viewsLoading={viewsLoading}
112117
getCheckStatus={getCheckStatus}
113-
onToggle={
114-
(view: View) => {
115-
const ids = toggleNode(view);
118+
onToggle={(view: View) => {
119+
const ids = toggleNode(view);
116120

117-
void handleToggle(Array.from(ids));
118-
}
119-
}
121+
void handleToggle(Array.from(ids));
122+
}}
120123
/>
121124
</div>
122125
</motion.div>

src/components/chat/types/request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export interface ChatSettings {
101101
name: string;
102102
rag_ids: string[];
103103
metadata: Record<string, unknown>;
104+
full_workspace?: boolean;
104105
web_search_enabled: boolean;
105106
}
106107

0 commit comments

Comments
 (0)