Chat Thread URL/Selection Sync
AI chat thread 선택을 Jotai state, per-thread draft, full-page URL, side-panel navigation에 동시에 반영하는 패턴이다.
핵심은 Thread selection is state first, URL projection second다.
1. 모듈 코드
src/modules/ai/hooks/useSelectAiChatThread.ts: thread state 전환과 URL projection을 하나의 선택 API로 묶는다.src/modules/ai/hooks/useSwitchAgentChatThreadWithDraft.ts: 현재 thread를 바꾸고 destination draft를 editor input에 복원한다.src/modules/ai/hooks/useProjectAiChatThreadToUrl.ts: 현재 화면이 full-page AI chat일 때만 selected thread를 URL query로 투영한다.src/modules/ai/hooks/useOpenAskAiThread.ts: nav drawer에 보이는 thread는 click flow로 열고, 보이지 않는 UUID는 state/URL을 직접 맞춘다.src/modules/ai/hooks/useOpenAiChatPage.ts: side panel을 닫고 full-page chat으로 이동하며 return location을 history state에 저장한다.src/modules/ai/hooks/useSwitchToNewAiChat.ts: 새 draft thread key로 전환하고 full-page 또는 side-panel chat을 연다.
// filepath: src/modules/ai/hooks/useSelectAiChatThread.ts
export const useSelectAiChatThread = () => {
const { switchThreadWithDraft } = useSwitchAgentChatThreadWithDraft();
const { projectAiChatThreadToUrl } = useProjectAiChatThreadToUrl();
const selectAiChatThread = (toThreadId: string) => {
switchThreadWithDraft(toThreadId);
projectAiChatThreadToUrl(toThreadId);
};
return { selectAiChatThread };
};
// filepath: src/modules/ai/hooks/useSwitchAgentChatThreadWithDraft.ts
export const useSwitchAgentChatThreadWithDraft = () => {
const [currentAiChatThread, setCurrentAiChatThread] = useAtomState(
currentAiChatThreadState,
);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const store = useStore();
const switchThreadWithDraft = useCallback(
(toThreadId: string) => {
const isSameThread = toThreadId === currentAiChatThread;
setCurrentAiChatThread(toThreadId);
if (!isSameThread) {
const destinationDraft =
store.get(agentChatDraftsByThreadIdState.atom)[toThreadId] ?? '';
setAgentChatInput(tipTapDocumentToMarkdown(destinationDraft));
}
},
[currentAiChatThread, setCurrentAiChatThread, setAgentChatInput, store],
);
return { switchThreadWithDraft };
};
// filepath: src/modules/ai/hooks/useProjectAiChatThreadToUrl.ts
export const useProjectAiChatThreadToUrl = () => {
const navigateApp = useNavigateApp();
const projectAiChatThreadToUrl = (threadId: string) => {
if (!isCurrentPathAiChatPage()) {
return;
}
navigateApp(
AppPath.AiChat,
{ threadId: isValidUuid(threadId) ? threadId : null },
undefined,
{ replace: true, state: getCurrentHistoryEntryState() },
);
};
return { projectAiChatThreadToUrl };
};
// filepath: src/modules/ai/hooks/useOpenAskAiThread.ts
export const useOpenAskAiThread = () => {
const agentChatVisibleThreads = useAtomStateValue(
agentChatVisibleThreadsSelector,
);
const { selectAiChatThread } = useSelectAiChatThread();
const { handleThreadClick } = useAiChatThreadClick({
resetNavigationStack: true,
});
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
const openAskAiThread = (threadId: string) => {
const thread = agentChatVisibleThreads.find(
(visibleThread) => visibleThread.id === threadId,
);
if (isDefined(thread)) {
handleThreadClick(thread);
return;
}
if (isValidUuid(threadId)) {
selectAiChatThread(threadId);
}
openAskAiPage({ resetNavigationStack: true });
};
return { openAskAiThread };
};
// filepath: src/modules/ai/hooks/useOpenAiChatPage.ts
export const useOpenAiChatPage = () => {
const navigate = useNavigateApp();
const { closeSidePanelMenu } = useSidePanelMenu();
const openAiChatPage = ({ threadId }: { threadId?: string | null } = {}) => {
if (isCurrentPathAiChatPage()) {
return;
}
void closeSidePanelMenu();
navigate(
AppPath.AiChat,
{ threadId: isDefined(threadId) && isValidUuid(threadId) ? threadId : null },
undefined,
{
state: {
returnLocation: `${window.location.pathname}${window.location.search}${window.location.hash}`,
},
},
);
};
return { openAiChatPage };
};
2. 사용 예제
// filepath: src/modules/ai/components/NavigationDrawerAiChatContent.tsx
export const NavigationDrawerAiChatContent = () => {
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
const { handleThreadClick } = useAiChatThreadClick({
resetNavigationStack: true,
});
const { threads } = useChatThreads();
return (
<NavigationDrawerAiChatThreadSection
title={t`Recents`}
threads={threads}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
/>
);
};
// filepath: src/modules/ai/hooks/useSwitchToNewAiChat.ts
export const useSwitchToNewAiChat = ({ shouldOpenInFullPage = false } = {}) => {
const { selectAiChatThread } = useSelectAiChatThread();
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
const { openAiChatPage } = useOpenAiChatPage();
const switchToNewChat = () => {
store.set(hasTriggeredCreateForDraftState.atom, false);
selectAiChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
shouldOpenInFullPage ? openAiChatPage() : openAskAiPage();
store.set(shouldFocusChatEditorState.atom, true);
};
return { switchToNewChat };
};