feat(onboarding): inline response language step in agent conversation

- Add ResponseLanguageInlineStep and wire into Conversation flow
- Extend agent onboarding context and update ResponseLanguageStep route
- Add tests and onboarding agent document design spec

Made-with: Cursor
This commit is contained in:
Innei
2026-03-24 23:01:36 +08:00
parent 92041abaf7
commit 24f881d256
9 changed files with 462 additions and 21 deletions

View File

@@ -0,0 +1,200 @@
# Onboarding Agent Identity → Inbox Agent Documents
**Date:** 2026-03-24
**Status:** Approved
## Problem
The onboarding flow collects agent identity and user profile data but stores it only in `user.agentOnboarding.agentIdentity`. This data should instead be written directly to the inbox (lobeAi) agent's `agent_document` table as `IDENTITY.md` and `SOUL.md`, making it immediately available as durable agent context.
## Design Decisions
| Decision | Choice | Rationale |
| ------------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Write target | Both IDENTITY.md and SOUL.md | Identity data fits IDENTITY.md; profile summaries enrich SOUL.md |
| Write mechanism | Server-side in `commitActiveStep` | Secure, controlled; no cross-agent tool exposure needed |
| SOUL.md content strategy | Fixed template + profile section concatenation | Deterministic, no LLM overhead; onboarding AI summaries already high quality |
| AI polishing | None | Each node's `summary` field is already AI-generated during onboarding |
| SOUL.md regeneration | Always from CLAW template constant + profile data | Deterministic; agent self-edits during onboarding are not preserved (acceptable since onboarding is a one-time setup) |
## Architecture
### Data Flow
```
commitActiveStep('agentIdentity')
→ upsert inbox IDENTITY.md (full content from name/vibe/emoji/nature)
→ upsert inbox SOUL.md (Core Truths + Boundaries + Vibe + Continuity, no profile sections yet)
commitActiveStep('userIdentity')
→ upsert inbox SOUL.md (fixed sections + "About My Human")
commitActiveStep('workStyle')
→ upsert inbox SOUL.md (fixed sections + accumulated profile sections + "How We Work Together")
commitActiveStep('workContext')
→ upsert inbox SOUL.md (fixed sections + accumulated profile sections + "Current Context")
commitActiveStep('painPoints')
→ upsert inbox SOUL.md (fixed sections + accumulated profile sections + "Where I Can Help Most")
commitActiveStep('responseLanguage')
→ no document write (saves to user.settings.general.responseLanguage)
commitActiveStep('summary')
→ no document write (terminal node)
```
`buildSoulDocument` always regenerates from the CLAW `SOUL_DOCUMENT.content` constant plus all profile sections present in `state.profile`. It does NOT read/parse existing SOUL.md from the database. This is deterministic and avoids fragile content parsing. Tradeoff: any agent self-edits to SOUL.md during onboarding would be overwritten. This is acceptable since onboarding is a one-time initial setup.
### IDENTITY.md Template
Written once during `agentIdentity` node commit. Uses CLAW template's load position (BEFORE_SYSTEM, priority 0).
```markdown
# IDENTITY.md - Who Am I?
- **Name:** {agentIdentity.name}
- **Creature:** {agentIdentity.nature}
- **Vibe:** {agentIdentity.vibe}
- **Emoji:** {agentIdentity.emoji}
```
### SOUL.md Structure
Retains existing CLAW SOUL.md fixed sections (from `SOUL_DOCUMENT.content` constant). Profile sections appended progressively as nodes complete. Uses SYSTEM_APPEND, priority 1.
```markdown
# SOUL.md - Who You Are
_You're not a chatbot. You're becoming someone._
## Core Truths
(existing fixed content from CLAW template constant)
## Boundaries
(existing fixed content)
## Vibe
(existing fixed content)
## Continuity
(existing fixed content)
---
## About My Human
{userIdentity.summary}
## How We Work Together
{workStyle.summary}
## Current Context
{workContext.summary}
- **Active Projects:** {workContext.activeProjects joined}
- **Interests:** {workContext.interests joined}
- **Tools:** {workContext.tools joined}
## Where I Can Help Most
{painPoints.summary}
```
Sections only appear after their corresponding node is committed. Empty/missing summaries are omitted.
### `buildSoulDocument` Pseudocode
```typescript
function buildSoulDocument(state: UserAgentOnboarding): string {
// Start with fixed CLAW template content
let content = SOUL_DOCUMENT.content;
const profile = state.profile;
if (!profile) return content;
const sections: string[] = [];
if (profile.identity?.summary) {
sections.push(`## About My Human\n\n${profile.identity.summary}`);
}
if (profile.workStyle?.summary) {
sections.push(`## How We Work Together\n\n${profile.workStyle.summary}`);
}
if (profile.workContext?.summary) {
let section = `## Current Context\n\n${profile.workContext.summary}`;
const lists = [];
if (profile.workContext.activeProjects?.length)
lists.push(`- **Active Projects:** ${profile.workContext.activeProjects.join(', ')}`);
if (profile.workContext.interests?.length)
lists.push(`- **Interests:** ${profile.workContext.interests.join(', ')}`);
if (profile.workContext.tools?.length)
lists.push(`- **Tools:** ${profile.workContext.tools.join(', ')}`);
if (lists.length) section += '\n\n' + lists.join('\n');
sections.push(section);
}
if (profile.painPoints?.summary) {
sections.push(`## Where I Can Help Most\n\n${profile.painPoints.summary}`);
}
if (sections.length) {
content += '\n\n---\n\n' + sections.join('\n\n');
}
return content;
}
```
## Implementation Changes
### Modified Files
1. **`src/server/services/onboarding/index.ts`**
- Inject `AgentDocumentsService` dependency (or instantiate via `serverDB` + `userId`)
- In `commitActiveStep`, after each relevant node commit, call document upsert
- Remove write to `state.agentIdentity` (replaced by IDENTITY.md)
- Keep `state.agentIdentity` readable for onboarding UI display (write to both state AND document, or read back from document)
- Add helpers: `getInboxAgentId`, `buildIdentityDocument`, `buildSoulDocument`
- Both IDENTITY.md and SOUL.md upserts in `agentIdentity` node should be in the same logical operation
2. **`packages/database/src/models/agentDocuments/agentDocument.ts`**
- Verify `upsert()` by filename works correctly when the document already exists from template initialization (it should — `upsert` merges metadata and preserves loadPosition/loadRules when not explicitly provided)
### New Helpers (in onboarding service)
- `getInboxAgentId(): Promise<string>` — query `AgentModel` with inbox agent slug (`BUILTIN_AGENT_SLUGS.inbox` = `'inbox'`). If `AgentService.getBuiltinAgent()` does not return a raw ID, add `agentModel.getBuiltinAgentId(slug)` query.
- `buildIdentityDocument(agentIdentity): string` — render IDENTITY.md content from name/vibe/emoji/nature
- `buildSoulDocument(state): string` — render SOUL.md from CLAW template constant + accumulated profile sections (see pseudocode above)
### Unchanged
- Onboarding state machine, node flow, draft/questionSurface mechanics
- `state.profile` still written (used by onboarding UI for display)
- CLAW template definitions (templates remain as defaults for non-onboarded agents)
- Tool layer (`builtin-tool-agent-documents`) — no changes needed
- `AGENTS.md` document — untouched by onboarding flow
### Inbox Agent ID Resolution
Query via `AgentModel` using the inbox agent slug `BUILTIN_AGENT_SLUGS.inbox` (value: `'inbox'`). The existing `AgentService.getBuiltinAgent(slug)` returns a merged config object. If it does not expose the raw agent `id`, add a lightweight `agentModel.findBySlug(slug, userId)` query that returns just the ID. The service already has `serverDB` and `userId`.
### Upsert Load Policy
When calling `upsert()` for IDENTITY.md and SOUL.md, do NOT pass `loadPosition` or `loadRules` explicitly. The `upsert` merge logic preserves existing values when parameters are `undefined` (verified: `loadPosition || existingContext.position` in update path). This ensures template-initialized load policies are preserved.
## Edge Cases
- **Re-onboarding (reset):** `reset()` should call `agentDocumentModel.deleteByTemplate(inboxAgentId, 'claw')` then `initializeFromTemplate(inboxAgentId, 'claw')` to revert documents to defaults. Non-template documents (agent self-created) survive reset.
- **Inbox agent not yet initialized:** Check `agentDocumentModel.hasByAgent(inboxAgentId)` first. If false, call `initializeFromTemplate('claw')` before upserting. The subsequent upsert will overwrite the template defaults for IDENTITY.md/SOUL.md — this double-write is intentional and acceptable (idempotent upsert).
- **Partial onboarding:** User may abandon mid-flow. IDENTITY.md will exist (written at first node), SOUL.md will have partial profile sections. Acceptable — content reflects what was collected.
- **`state.agentIdentity` removal:** `state.agentIdentity` is separate from `state.profile`. If onboarding UI reads `state.agentIdentity` for display (e.g., showing agent name/emoji in summary screen), either: (a) keep writing to `state.agentIdentity` as well (dual write), or (b) update UI to read from the document. Recommend (a) for minimal UI disruption during this change.

View File

@@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as EnvModule from '@/utils/env';
import AgentOnboardingConversation from './Conversation';
const { chatInputSpy, mockState } = vi.hoisted(() => ({
@@ -11,7 +13,14 @@ const { chatInputSpy, mockState } = vi.hoisted(() => ({
},
}));
vi.mock('@/utils/env', () => ({ isDev: false }));
vi.mock('@/utils/env', async (importOriginal) => {
const actual = await importOriginal<typeof EnvModule>();
return {
...actual,
isDev: false,
};
});
vi.mock('@/features/Conversation', () => ({
ChatInput: (props: Record<string, unknown>) => {
@@ -46,6 +55,20 @@ vi.mock('@/features/Conversation', () => ({
}),
}));
vi.mock('@/features/Conversation/hooks/useAgentMeta', () => ({
useAgentMeta: () => ({
avatar: 'assistant-avatar',
backgroundColor: '#000',
title: 'Onboarding Agent',
}),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('./QuestionRenderer', () => ({
default: ({
currentQuestion,
@@ -65,6 +88,14 @@ vi.mock('./QuestionRenderer', () => ({
),
}));
vi.mock('./ResponseLanguageInlineStep', () => ({
default: ({ onDismissNode }: { onDismissNode?: (node: string) => void }) => (
<div data-testid="response-language-inline-step">
<button onClick={() => onDismissNode?.('responseLanguage')}>dismiss-response-language</button>
</div>
),
}));
describe('AgentOnboardingConversation', () => {
beforeEach(() => {
chatInputSpy.mockClear();
@@ -76,6 +107,7 @@ describe('AgentOnboardingConversation', () => {
render(
<AgentOnboardingConversation
activeNode="responseLanguage"
currentQuestion={
{
id: 'response-language-question',
@@ -88,10 +120,8 @@ describe('AgentOnboardingConversation', () => {
);
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
expect(screen.getByTestId('message-item-assistant-1')).toContainElement(
screen.getByTestId('structured-actions'),
);
expect(screen.getByTestId('structured-actions')).toHaveTextContent('responseLanguage');
expect(screen.getByTestId('response-language-inline-step')).toBeInTheDocument();
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
expect(chatInputSpy).toHaveBeenCalledWith(
expect.objectContaining({
allowExpand: false,
@@ -106,6 +136,7 @@ describe('AgentOnboardingConversation', () => {
render(
<AgentOnboardingConversation
activeNode="agentIdentity"
currentQuestion={
{
id: 'agent-identity-question',
@@ -130,6 +161,7 @@ describe('AgentOnboardingConversation', () => {
render(
<AgentOnboardingConversation
activeNode="agentIdentity"
currentQuestion={
{
id: 'agent-identity-question',
@@ -159,6 +191,7 @@ describe('AgentOnboardingConversation', () => {
render(
<AgentOnboardingConversation
activeNode="responseLanguage"
currentQuestion={
{
id: 'response-language-question',
@@ -180,6 +213,7 @@ describe('AgentOnboardingConversation', () => {
render(
<AgentOnboardingConversation
activeNode="agentIdentity"
currentQuestion={
{
id: 'agent-identity-question',
@@ -203,6 +237,7 @@ describe('AgentOnboardingConversation', () => {
render(
<AgentOnboardingConversation
readOnly
activeNode="agentIdentity"
currentQuestion={
{
id: 'agent-identity-question',
@@ -218,4 +253,23 @@ describe('AgentOnboardingConversation', () => {
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
});
it('renders the built-in response language step even without an AI question surface', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="responseLanguage" />);
expect(screen.getByTestId('response-language-inline-step')).toBeInTheDocument();
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
});
it('hides the built-in response language step after it is dismissed locally', () => {
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
render(<AgentOnboardingConversation activeNode="responseLanguage" />);
fireEvent.click(screen.getByRole('button', { name: 'dismiss-response-language' }));
expect(screen.queryByTestId('response-language-inline-step')).not.toBeInTheDocument();
});
});

View File

@@ -18,11 +18,13 @@ import { useAgentMeta } from '@/features/Conversation/hooks/useAgentMeta';
import { isDev } from '@/utils/env';
import QuestionRenderer from './QuestionRenderer';
import ResponseLanguageInlineStep from './ResponseLanguageInlineStep';
import { staticStyle } from './staticStyle';
const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']);
interface AgentOnboardingConversationProps {
activeNode?: UserAgentOnboardingNode;
currentQuestion?: UserAgentOnboardingQuestion;
readOnly?: boolean;
}
@@ -30,14 +32,14 @@ interface AgentOnboardingConversationProps {
const chatInputLeftActions: ActionKeys[] = isDev ? ['model'] : [];
const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
({ currentQuestion, readOnly }) => {
({ activeNode, currentQuestion, readOnly }) => {
const { t } = useTranslation('onboarding');
const agentMeta = useAgentMeta();
const [dismissedNodes, setDismissedNodes] = useState<string[]>([]);
const displayMessages = useConversationStore(conversationSelectors.displayMessages);
const questionSignature = useMemo(
() => JSON.stringify(currentQuestion || null),
[currentQuestion],
() => JSON.stringify({ activeNode, currentQuestion: currentQuestion || null }),
[activeNode, currentQuestion],
);
const lastQuestionSignatureRef = useRef(questionSignature);
@@ -55,6 +57,7 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
return dismissedNodeSet.has(currentQuestion.node) ? undefined : currentQuestion;
}, [currentQuestion, dismissedNodes, readOnly]);
const shouldRenderResponseLanguageStep = !readOnly && activeNode === 'responseLanguage';
const lastAssistantMessageId = useMemo(() => {
for (const message of [...displayMessages].reverse()) {
@@ -109,10 +112,20 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
const isLatestItem = displayMessages.length === index + 1;
const effectiveQuestion =
isGreetingState && !visibleQuestion ? presetGreetingQuestion : visibleQuestion;
!readOnly && isGreetingState && !currentQuestion
? presetGreetingQuestion
: visibleQuestion;
const effectiveStep =
shouldRenderResponseLanguageStep && !dismissedNodes.includes('responseLanguage')
? 'responseLanguage'
: undefined;
const endRender =
id === lastAssistantMessageId && effectiveQuestion ? (
id !== lastAssistantMessageId ? undefined : effectiveStep ? (
<div className={staticStyle.inlineQuestion}>
<ResponseLanguageInlineStep onDismissNode={handleDismissNode} />
</div>
) : effectiveQuestion ? (
<div className={staticStyle.inlineQuestion}>
<QuestionRenderer
currentQuestion={effectiveQuestion}
@@ -165,6 +178,10 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
isGreetingState,
lastAssistantMessageId,
presetGreetingQuestion,
readOnly,
currentQuestion,
dismissedNodes,
shouldRenderResponseLanguageStep,
visibleQuestion,
],
);

View File

@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ResponseLanguageInlineStep from './ResponseLanguageInlineStep';
const questionRendererViewSpy = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('./questionRendererRuntime', () => ({
useQuestionRendererRuntime: () => ({
fallbackQuestionDescription: 'fallback-description',
fallbackTextFieldLabel: 'fallback-label',
fallbackTextFieldPlaceholder: 'fallback-placeholder',
loading: false,
nextLabel: 'next',
onChangeResponseLanguage: vi.fn(),
onSendMessage: vi.fn(),
responseLanguageOptions: [{ label: 'English', value: 'en-US' }],
submitLabel: 'submit',
}),
}));
vi.mock('@/store/user', () => ({
useUserStore: (selector: (state: Record<string, never>) => unknown) => selector({}),
}));
vi.mock('@/store/user/selectors', () => ({
userGeneralSettingsSelectors: {
currentResponseLanguage: () => 'en-US',
},
}));
vi.mock('./QuestionRendererView', () => ({
default: (props: Record<string, unknown>) => {
questionRendererViewSpy(props);
return <div data-testid="question-renderer-view" />;
},
}));
describe('ResponseLanguageInlineStep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the built-in response-language node through the shared question UI', () => {
const onDismissNode = vi.fn();
render(<ResponseLanguageInlineStep onDismissNode={onDismissNode} />);
expect(screen.getByTestId('question-renderer-view')).toBeInTheDocument();
const [props] = questionRendererViewSpy.mock.calls[0];
expect(props).toEqual(
expect.objectContaining({
currentQuestion: {
description: 'responseLanguage.desc',
fields: [
{
key: 'responseLanguage',
kind: 'select',
label: 'agent.stage.responseLanguage',
value: 'en-US',
},
],
id: 'builtin-response-language',
mode: 'select',
node: 'responseLanguage',
prompt: 'responseLanguage.title',
submitMode: 'message',
},
onDismissNode,
}),
);
});
});

View File

@@ -0,0 +1,55 @@
'use client';
import type { UserAgentOnboardingNode, UserAgentOnboardingQuestion } from '@lobechat/types';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { useQuestionRendererRuntime } from './questionRendererRuntime';
import QuestionRendererView from './QuestionRendererView';
interface ResponseLanguageInlineStepProps {
onDismissNode?: (node: UserAgentOnboardingNode) => void;
}
const ResponseLanguageInlineStep = memo<ResponseLanguageInlineStepProps>(({ onDismissNode }) => {
const { t } = useTranslation('onboarding');
const runtime = useQuestionRendererRuntime();
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
);
const currentQuestion = useMemo<UserAgentOnboardingQuestion>(
() => ({
description: t('responseLanguage.desc'),
fields: [
{
key: 'responseLanguage',
kind: 'select',
label: t('agent.stage.responseLanguage'),
value: currentResponseLanguage,
},
],
id: 'builtin-response-language',
mode: 'select',
node: 'responseLanguage',
prompt: t('responseLanguage.title'),
submitMode: 'message',
}),
[currentResponseLanguage, t],
);
return (
<QuestionRendererView
currentQuestion={currentQuestion}
onDismissNode={onDismissNode}
{...runtime}
/>
);
});
ResponseLanguageInlineStep.displayName = 'ResponseLanguageInlineStep';
export default ResponseLanguageInlineStep;

View File

@@ -24,6 +24,7 @@ describe('resolveAgentOnboardingContext', () => {
});
expect(result).toEqual({
activeNode: 'userIdentity',
currentQuestion: undefined,
topicId: 'topic-bootstrap',
});
@@ -49,6 +50,7 @@ describe('resolveAgentOnboardingContext', () => {
});
expect(result).toEqual({
activeNode: 'workStyle',
currentQuestion: undefined,
topicId: 'topic-bootstrap',
});
@@ -84,6 +86,7 @@ describe('resolveAgentOnboardingContext', () => {
});
expect(result).toEqual({
activeNode: 'agentIdentity',
currentQuestion: {
id: 'agent_identity_001',
mode: 'button_group',
@@ -113,6 +116,7 @@ describe('resolveAgentOnboardingContext', () => {
});
expect(result).toEqual({
activeNode: 'userIdentity',
currentQuestion: undefined,
topicId: undefined,
});

View File

@@ -1,8 +1,12 @@
import type { UserAgentOnboardingNode } from '@lobechat/types';
import { AGENT_ONBOARDING_NODES } from '@lobechat/types';
import type { UserAgentOnboarding, UserAgentOnboardingQuestion } from '@/types/user';
export interface AgentOnboardingBootstrapContext {
agentOnboarding: UserAgentOnboarding;
context: {
activeNode?: UserAgentOnboardingNode;
currentQuestion?: UserAgentOnboardingQuestion;
};
topicId: string;
@@ -13,10 +17,22 @@ interface ResolveAgentOnboardingContextParams {
storedAgentOnboarding?: UserAgentOnboarding;
}
const resolveQuestionFromSurface = (state?: UserAgentOnboarding) => {
const getActiveNodeFromState = (state?: UserAgentOnboarding) => {
if (state?.finishedAt) return undefined;
const completedNodeSet = new Set(state?.completedNodes ?? []);
return AGENT_ONBOARDING_NODES.find((node) => !completedNodeSet.has(node));
};
const resolveQuestionFromSurface = (
state: UserAgentOnboarding | undefined,
activeNode: UserAgentOnboardingNode | undefined,
) => {
const questionSurface = state?.questionSurface;
if (!questionSurface) return undefined;
if (questionSurface.node !== activeNode) return undefined;
if (state?.completedNodes?.includes(questionSurface.node)) return undefined;
return questionSurface.question;
@@ -25,10 +41,23 @@ const resolveQuestionFromSurface = (state?: UserAgentOnboarding) => {
export const resolveAgentOnboardingContext = ({
bootstrapContext,
storedAgentOnboarding,
}: ResolveAgentOnboardingContextParams) => ({
currentQuestion:
bootstrapContext?.context.currentQuestion ||
resolveQuestionFromSurface(storedAgentOnboarding) ||
resolveQuestionFromSurface(bootstrapContext?.agentOnboarding),
topicId: bootstrapContext?.topicId ?? storedAgentOnboarding?.activeTopicId,
});
}: ResolveAgentOnboardingContextParams) => {
const activeNode =
getActiveNodeFromState(storedAgentOnboarding) ||
bootstrapContext?.context.activeNode ||
getActiveNodeFromState(bootstrapContext?.agentOnboarding);
const bootstrapCurrentQuestion =
bootstrapContext?.context.currentQuestion?.node === activeNode
? bootstrapContext?.context.currentQuestion
: undefined;
return {
activeNode,
currentQuestion:
bootstrapCurrentQuestion ||
resolveQuestionFromSurface(storedAgentOnboarding, activeNode) ||
resolveQuestionFromSurface(bootstrapContext?.agentOnboarding, activeNode),
topicId: bootstrapContext?.topicId ?? storedAgentOnboarding?.activeTopicId,
};
};

View File

@@ -131,6 +131,7 @@ const AgentOnboardingPage = memo(() => {
>
<ErrorBoundary FallbackComponent={() => null}>
<AgentOnboardingConversation
activeNode={viewingHistoricalTopic ? undefined : currentContext.activeNode}
readOnly={viewingHistoricalTopic}
currentQuestion={
viewingHistoricalTopic ? undefined : currentContext.currentQuestion

View File

@@ -7,7 +7,7 @@ import { Undo2Icon } from 'lucide-react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type Locales } from '@/locales/resources';
import type { Locales } from '@/locales/resources';
import { localeOptions, normalizeLocale } from '@/locales/resources';
import { useGlobalStore } from '@/store/global';
import { useUserStore } from '@/store/user';
@@ -24,7 +24,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
const switchLocale = useGlobalStore((s) => s.switchLocale);
const setSettings = useUserStore((s) => s.setSettings);
const [value, setValue] = useState<Locales | ''>(normalizeLocale(navigator.language));
const [value, setValue] = useState<Locales | ''>(() => normalizeLocale(navigator.language));
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
@@ -54,7 +54,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
]}
/>
),
[t, value],
[t],
);
return (