@vibe-forge/client 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/cli.cjs +6 -1
  2. package/dist/assets/{arc-M4HYfcHs.js → arc-De_WjPJ3.js} +1 -1
  3. package/dist/assets/{blockDiagram-c4efeb88-CUrDjrxj.js → blockDiagram-c4efeb88-C4aR2zTE.js} +1 -1
  4. package/dist/assets/{c4Diagram-c83219d4-BMEtqlFp.js → c4Diagram-c83219d4-BZH3rq_m.js} +1 -1
  5. package/dist/assets/channel-BvERb8WU.js +1 -0
  6. package/dist/assets/{classDiagram-beda092f-BOmDJ0Ml.js → classDiagram-beda092f-BzJgBrIK.js} +1 -1
  7. package/dist/assets/{classDiagram-v2-2358418a-BODzX2MB.js → classDiagram-v2-2358418a-5ZtXcnT3.js} +1 -1
  8. package/dist/assets/clone-B9_0v-6Y.js +1 -0
  9. package/dist/assets/{createText-1719965b-B9Dd8zcR.js → createText-1719965b-DUVvEtmR.js} +1 -1
  10. package/dist/assets/{cssMode-DLxG92Ot.js → cssMode-GoTNjuXX.js} +1 -1
  11. package/dist/assets/{edges-96097737-CuZFd43m.js → edges-96097737-Dd7m4Cvs.js} +1 -1
  12. package/dist/assets/{erDiagram-0228fc6a-8g9lu2-Z.js → erDiagram-0228fc6a-DxqFlG_f.js} +1 -1
  13. package/dist/assets/{flowDb-c6c81e3f-BlBS1tdN.js → flowDb-c6c81e3f-DU0C5kCI.js} +1 -1
  14. package/dist/assets/{flowDiagram-50d868cf-u6mWflpF.js → flowDiagram-50d868cf-Di1uDa_X.js} +1 -1
  15. package/dist/assets/flowDiagram-v2-4f6560a1-LpS8Kb00.js +1 -0
  16. package/dist/assets/{flowchart-elk-definition-6af322e1-BDqI2NFr.js → flowchart-elk-definition-6af322e1-CwG8aty5.js} +1 -1
  17. package/dist/assets/{freemarker2-tVtpTMPu.js → freemarker2-j39cqTlI.js} +1 -1
  18. package/dist/assets/{ganttDiagram-a2739b55-CDQjx9Wu.js → ganttDiagram-a2739b55-baO_lzL-.js} +1 -1
  19. package/dist/assets/{gitGraphDiagram-82fe8481-DUHFKRVA.js → gitGraphDiagram-82fe8481-COoHjYMf.js} +1 -1
  20. package/dist/assets/{graph-2HKPi5B_.js → graph-KxESr4M5.js} +1 -1
  21. package/dist/assets/{handlebars-D00tgNd8.js → handlebars-BgjdZO8G.js} +1 -1
  22. package/dist/assets/{html-B-TDzBiR.js → html-Ba7tYObe.js} +1 -1
  23. package/dist/assets/{htmlMode-ClycqSTM.js → htmlMode-Bztvbig1.js} +1 -1
  24. package/dist/assets/{index-5325376f-DPrJpRQ-.js → index-5325376f-BMTAx2mL.js} +1 -1
  25. package/dist/assets/index-C1oh0w9H.css +32 -0
  26. package/dist/assets/{index-CAHZZEoo.js → index-Pm_kLJvG.js} +330 -326
  27. package/dist/assets/{infoDiagram-8eee0895-Co5tS1I5.js → infoDiagram-8eee0895-CC74qbHY.js} +1 -1
  28. package/dist/assets/{javascript-zbkwarmb.js → javascript-C1e1cllX.js} +1 -1
  29. package/dist/assets/{journeyDiagram-c64418c1-k_qioHgy.js → journeyDiagram-c64418c1-C4MyOdE6.js} +1 -1
  30. package/dist/assets/{jsonMode-C3CSpzBF.js → jsonMode-BC98AlvF.js} +1 -1
  31. package/dist/assets/{layout-CjOXKxvs.js → layout-CxAyTlr7.js} +1 -1
  32. package/dist/assets/{line-C-XnQrKR.js → line-DhaUfI71.js} +1 -1
  33. package/dist/assets/{linear-C7MMERzS.js → linear-MYukzldK.js} +1 -1
  34. package/dist/assets/{liquid-5G37EU6K.js → liquid-DahfJEYl.js} +1 -1
  35. package/dist/assets/{lspLanguageFeatures-zaDMuhCE.js → lspLanguageFeatures-BWDJcswW.js} +1 -1
  36. package/dist/assets/{mdx-Bc-LY0gi.js → mdx-BELlF_FD.js} +1 -1
  37. package/dist/assets/{mermaid.core-CechbHof.js → mermaid.core-BrQnSGSY.js} +4 -4
  38. package/dist/assets/{mindmap-definition-8da855dc-ejftCDGb.js → mindmap-definition-8da855dc-B0FoxTiy.js} +1 -1
  39. package/dist/assets/{pieDiagram-a8764435-DY__X3Qj.js → pieDiagram-a8764435-Ddr2cjSL.js} +1 -1
  40. package/dist/assets/{python-vK2Ff2J5.js → python--C9if_AD.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-1e28029f-azIZCv_2.js → quadrantDiagram-1e28029f-BlEs7Mrl.js} +1 -1
  42. package/dist/assets/{razor-BipjBJKu.js → razor-B9U9JxKn.js} +1 -1
  43. package/dist/assets/{requirementDiagram-08caed73-C4EB0Xs2.js → requirementDiagram-08caed73-kEFOAu2v.js} +1 -1
  44. package/dist/assets/{sankeyDiagram-a04cb91d-PNhR6YWu.js → sankeyDiagram-a04cb91d-BBghez8I.js} +1 -1
  45. package/dist/assets/{sequenceDiagram-c5b8d532-4c-qV-Ri.js → sequenceDiagram-c5b8d532-CJqgzdUE.js} +1 -1
  46. package/dist/assets/{stateDiagram-1ecb1508-CnURumPE.js → stateDiagram-1ecb1508-BER4XEI6.js} +1 -1
  47. package/dist/assets/{stateDiagram-v2-c2b004d7-DR2qHTPg.js → stateDiagram-v2-c2b004d7-EBV2vSks.js} +1 -1
  48. package/dist/assets/{styles-b4e223ce-B2PWXT_i.js → styles-b4e223ce-k0eswZsE.js} +1 -1
  49. package/dist/assets/{styles-ca3715f6-DEhgVF5H.js → styles-ca3715f6-Ckr7GA-0.js} +1 -1
  50. package/dist/assets/{styles-d45a18b0-DyzccA5F.js → styles-d45a18b0-C1bpSwV3.js} +1 -1
  51. package/dist/assets/{svgDrawCommon-b86b1483-C_1tMhxp.js → svgDrawCommon-b86b1483-CDtKpGvy.js} +1 -1
  52. package/dist/assets/{timeline-definition-faaaa080-FdaC0dQH.js → timeline-definition-faaaa080-BeGR-vua.js} +1 -1
  53. package/dist/assets/{tsMode-CrMC5T3_.js → tsMode-D_gJXIy3.js} +1 -1
  54. package/dist/assets/{typescript-CRfPu8v7.js → typescript-BoKcNXkN.js} +1 -1
  55. package/dist/assets/{xml-jlRvQfFI.js → xml-DZvURlJ-.js} +1 -1
  56. package/dist/assets/{xychartDiagram-f5964ef8-sxjv75h9.js → xychartDiagram-f5964ef8-DxfeLuYV.js} +1 -1
  57. package/dist/assets/{yaml-B47_IHOH.js → yaml-CTC8PAGY.js} +1 -1
  58. package/dist/index.html +2 -2
  59. package/package.json +9 -9
  60. package/src/api/git.ts +78 -0
  61. package/src/api.ts +24 -0
  62. package/src/components/chat/ChatHeader.tsx +4 -0
  63. package/src/components/chat/git-controls/BranchSwitcherDropdown.tsx +157 -0
  64. package/src/components/chat/git-controls/ChatGitControls.scss +616 -0
  65. package/src/components/chat/git-controls/ChatGitControls.tsx +151 -0
  66. package/src/components/chat/git-controls/GitCommitModal.tsx +199 -0
  67. package/src/components/chat/git-controls/GitCommitModalParts.tsx +151 -0
  68. package/src/components/chat/git-controls/GitOperationsDropdown.tsx +123 -0
  69. package/src/components/chat/git-controls/GitPushModal.tsx +106 -0
  70. package/src/components/chat/git-controls/GitWorktreeDropdown.tsx +68 -0
  71. package/src/components/chat/git-controls/git-branch-utils.ts +88 -0
  72. package/src/components/chat/git-controls/git-commit-utils.ts +79 -0
  73. package/src/components/chat/git-controls/git-mutation-utils.ts +69 -0
  74. package/src/components/chat/git-controls/git-operation-utils.ts +98 -0
  75. package/src/components/chat/git-controls/git-worktree-utils.ts +49 -0
  76. package/src/components/chat/git-controls/use-chat-git-commit.ts +185 -0
  77. package/src/components/chat/git-controls/use-chat-git-controls.ts +200 -0
  78. package/src/components/chat/git-controls/use-chat-git-push-state.ts +19 -0
  79. package/src/components/chat/git-controls/use-chat-git-worktrees.ts +39 -0
  80. package/src/components/chat/tools/DefaultTool.tsx +40 -31
  81. package/src/components/chat/tools/adapter-claude/GenericClaudeTool.tsx +1 -15
  82. package/src/components/chat/tools/adapter-claude/claude-tool-edit-builders.ts +8 -1
  83. package/src/components/chat/tools/adapter-claude/claude-tool-field-sections.tsx +10 -95
  84. package/src/components/chat/tools/core/ToolCallBox.scss +18 -0
  85. package/src/components/chat/tools/core/generic-tool-presentation.ts +661 -0
  86. package/src/components/chat/tools/core/tool-display.ts +12 -1
  87. package/src/components/chat/tools/core/tool-field-sections.tsx +132 -0
  88. package/src/components/chat/tools/core/tool-summary.ts +18 -6
  89. package/src/components/chat/tools/plugin-chrome-devtools/ChromeDevtoolsTool.tsx +0 -7
  90. package/src/hooks/chat/session-view-cache.ts +80 -0
  91. package/src/hooks/chat/use-chat-session-messages.ts +124 -30
  92. package/src/resources/locales/en.json +72 -4
  93. package/src/resources/locales/zh.json +72 -4
  94. package/dist/assets/channel-Cj3Cp2OJ.js +0 -1
  95. package/dist/assets/clone-B7Q9B1dS.js +0 -1
  96. package/dist/assets/flowDiagram-v2-4f6560a1-G3v545eF.js +0 -1
  97. package/dist/assets/index-Di7lePfb.css +0 -32
@@ -0,0 +1,132 @@
1
+ import { Tooltip } from 'antd'
2
+ import React from 'react'
3
+
4
+ import { CodeBlock } from '#~/components/CodeBlock'
5
+ import { safeJsonStringify } from '#~/utils/safe-serialize'
6
+
7
+ import { TOOL_TOOLTIP_PROPS, getToolFieldIcon, getToolInlineValueText, getToolValueText } from './tool-display'
8
+
9
+ type Translate = (key: string, options?: Record<string, unknown>) => string
10
+
11
+ export type ToolFieldFormat = 'inline' | 'text' | 'code' | 'list' | 'json' | 'questions'
12
+
13
+ export interface ToolFieldView {
14
+ labelKey: string
15
+ fallbackLabel: string
16
+ format: ToolFieldFormat
17
+ value: unknown
18
+ lang?: string
19
+ }
20
+
21
+ const getFieldKey = (field: ToolFieldView, index: number) => `${field.labelKey}-${index}`
22
+
23
+ const getSectionHeader = (icon: string, label: string) => (
24
+ <div className='tool-detail-section__header'>
25
+ <Tooltip title={label} {...TOOL_TOOLTIP_PROPS}>
26
+ <span className='tool-detail-section__icon material-symbols-rounded'>{icon}</span>
27
+ </Tooltip>
28
+ </div>
29
+ )
30
+
31
+ export function ToolInlineFields({
32
+ fields,
33
+ t
34
+ }: {
35
+ fields: ToolFieldView[]
36
+ t: Translate
37
+ }) {
38
+ if (fields.length === 0) {
39
+ return null
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className='tool-inline-token-list tool-inline-token-list--standalone'
45
+ aria-label={t('chat.tools.fields.details')}
46
+ >
47
+ {fields.map((field, index) => {
48
+ const label = t(field.labelKey, { defaultValue: field.fallbackLabel })
49
+ const valueText = getToolInlineValueText(field.value)
50
+ return (
51
+ <Tooltip
52
+ key={getFieldKey(field, index)}
53
+ title={
54
+ <div className='tool-tooltip-content'>
55
+ <div className='tool-tooltip-content__title'>{label}</div>
56
+ <div className='tool-tooltip-content__value'>{getToolValueText(field.value)}</div>
57
+ </div>
58
+ }
59
+ {...TOOL_TOOLTIP_PROPS}
60
+ >
61
+ <div className='tool-inline-token'>
62
+ <span className='tool-inline-token__icon material-symbols-rounded'>
63
+ {getToolFieldIcon(field.labelKey, field.format)}
64
+ </span>
65
+ <span className='tool-inline-token__value'>{valueText}</span>
66
+ </div>
67
+ </Tooltip>
68
+ )
69
+ })}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ export function renderToolBlockField(
75
+ field: ToolFieldView,
76
+ index: number,
77
+ t: Translate,
78
+ options: {
79
+ sectionClassName?: string
80
+ } = {}
81
+ ) {
82
+ const label = t(field.labelKey, { defaultValue: field.fallbackLabel })
83
+ const sectionHeader = getSectionHeader(getToolFieldIcon(field.labelKey, field.format), label)
84
+ const sectionClassName = options.sectionClassName ?? 'tool-detail-section'
85
+
86
+ if (field.format === 'text') {
87
+ return (
88
+ <div className={sectionClassName} key={getFieldKey(field, index)}>
89
+ {sectionHeader}
90
+ <div className='tool-detail-section__text'>{String(field.value)}</div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ if (field.format === 'code') {
96
+ return (
97
+ <div className={sectionClassName} key={getFieldKey(field, index)}>
98
+ {sectionHeader}
99
+ <CodeBlock
100
+ code={String(field.value)}
101
+ lang={field.lang ?? 'text'}
102
+ hideHeader={true}
103
+ />
104
+ </div>
105
+ )
106
+ }
107
+
108
+ if (field.format === 'list') {
109
+ const items = Array.isArray(field.value) ? field.value.map(item => String(item)) : []
110
+ return (
111
+ <div className={sectionClassName} key={getFieldKey(field, index)}>
112
+ {sectionHeader}
113
+ <div className='tool-detail-list'>
114
+ {items.map(listItem => (
115
+ <div className='tool-detail-list-item' key={listItem}>{listItem}</div>
116
+ ))}
117
+ </div>
118
+ </div>
119
+ )
120
+ }
121
+
122
+ return (
123
+ <div className={sectionClassName} key={getFieldKey(field, index)}>
124
+ {sectionHeader}
125
+ <CodeBlock
126
+ code={safeJsonStringify(field.value, 2)}
127
+ lang='json'
128
+ hideHeader={true}
129
+ />
130
+ </div>
131
+ )
132
+ }
@@ -1,3 +1,5 @@
1
+ /* eslint-disable max-lines */
2
+
1
3
  import type { ChatMessageContent } from '@vibe-forge/core'
2
4
 
3
5
  import {
@@ -6,6 +8,7 @@ import {
6
8
  isClaudeToolName
7
9
  } from '../adapter-claude/claude-tool-presentation'
8
10
  import { getClaudeToolSummaryText } from '../adapter-claude/claude-tool-summary'
11
+ import { buildGenericToolPresentation } from './generic-tool-presentation'
9
12
 
10
13
  export type ToolUseItem = Extract<ChatMessageContent, { type: 'tool_use' }>
11
14
  type Translate = (key: string, options?: Record<string, unknown>) => string
@@ -28,7 +31,7 @@ export const formatToolName = (name: string) => {
28
31
  return name.replace('mcp__ChromeDevtools__', '')
29
32
  }
30
33
 
31
- const namespaceSegments = name.split('__').filter(Boolean)
34
+ const namespaceSegments = name.includes('__') ? name.split('__').filter(Boolean) : []
32
35
  const lastSegment = namespaceSegments.length > 0
33
36
  ? namespaceSegments[namespaceSegments.length - 1]
34
37
  : name.split(':').pop() ?? name
@@ -76,8 +79,11 @@ export function getToolSummaryText(item: ToolUseItem, t: Translate) {
76
79
  return getClaudeToolSummaryText(item.name, item.input, t)
77
80
  }
78
81
 
79
- const displayName = formatToolName(item.name)
80
- const preview = getToolInputPreview(item.input)
82
+ const presentation = buildGenericToolPresentation(item.name, item.input)
83
+ const displayName = presentation.titleKey != null
84
+ ? t(presentation.titleKey, { defaultValue: presentation.fallbackTitle })
85
+ : presentation.fallbackTitle
86
+ const preview = presentation.primary ?? getToolInputPreview(item.input)
81
87
  return preview != null && preview !== '' ? `${displayName} ${preview}` : displayName
82
88
  }
83
89
 
@@ -87,7 +93,10 @@ export function getToolTitleText(item: ToolUseItem, t: Translate) {
87
93
  return t(presentation.titleKey, { defaultValue: presentation.fallbackTitle })
88
94
  }
89
95
 
90
- return formatToolName(item.name)
96
+ const presentation = buildGenericToolPresentation(item.name, item.input)
97
+ return presentation.titleKey != null
98
+ ? t(presentation.titleKey, { defaultValue: presentation.fallbackTitle })
99
+ : presentation.fallbackTitle
91
100
  }
92
101
 
93
102
  export function getToolPrimaryText(item: ToolUseItem) {
@@ -95,7 +104,7 @@ export function getToolPrimaryText(item: ToolUseItem) {
95
104
  return buildClaudeToolPresentation(item.name, item.input).primary
96
105
  }
97
106
 
98
- return getToolInputPreview(item.input)
107
+ return buildGenericToolPresentation(item.name, item.input).primary ?? getToolInputPreview(item.input)
99
108
  }
100
109
 
101
110
  const getToolNamespaceLabel = (name: string) => {
@@ -133,7 +142,10 @@ function getToolGroupDescriptor(item: ToolUseItem, t: Translate): ToolGroupDescr
133
142
  }
134
143
  }
135
144
 
136
- const label = formatToolName(item.name)
145
+ const presentation = buildGenericToolPresentation(item.name, item.input)
146
+ const label = presentation.titleKey != null
147
+ ? t(presentation.titleKey, { defaultValue: presentation.fallbackTitle })
148
+ : presentation.fallbackTitle
137
149
  return {
138
150
  key: item.name,
139
151
  label,
@@ -69,13 +69,6 @@ export const ChromeDevtoolsTool = defineToolRender(({ item, resultItem }) => {
69
69
  )}
70
70
  {hasResultDetails && resultItem != null && (
71
71
  <div className='tool-detail-section'>
72
- <div className='tool-detail-section__header'>
73
- <Tooltip title={t('chat.result')} {...TOOL_TOOLTIP_PROPS}>
74
- <span className='tool-detail-section__icon material-symbols-rounded'>
75
- {getToolSectionIcon('result')}
76
- </span>
77
- </Tooltip>
78
- </div>
79
72
  <ToolResultContent content={resultItem.content} />
80
73
  </div>
81
74
  )}
@@ -0,0 +1,80 @@
1
+ import type { ChatMessage } from '@vibe-forge/core'
2
+ import type { SessionInfo } from '@vibe-forge/types'
3
+
4
+ import type { ChatErrorState, InteractionRequestState } from './interaction-state'
5
+
6
+ export interface ChatSessionViewSnapshot {
7
+ messages: ChatMessage[]
8
+ sessionInfo: SessionInfo | null
9
+ errorState: ChatErrorState | null
10
+ interactionRequest: InteractionRequestState | null
11
+ isHydrated: boolean
12
+ }
13
+
14
+ export const MAX_CHAT_SESSION_VIEW_SNAPSHOTS = 20
15
+
16
+ export const createChatSessionViewSnapshot = (
17
+ value?: Partial<ChatSessionViewSnapshot>
18
+ ): ChatSessionViewSnapshot => ({
19
+ messages: value?.messages ?? [],
20
+ sessionInfo: value?.sessionInfo ?? null,
21
+ errorState: value?.errorState ?? null,
22
+ interactionRequest: value?.interactionRequest ?? null,
23
+ isHydrated: value?.isHydrated ?? false
24
+ })
25
+
26
+ export const mergeChatSessionViewSnapshot = (
27
+ current: ChatSessionViewSnapshot | undefined,
28
+ patch: Partial<ChatSessionViewSnapshot>
29
+ ): ChatSessionViewSnapshot => {
30
+ return createChatSessionViewSnapshot({
31
+ ...createChatSessionViewSnapshot(current),
32
+ ...patch
33
+ })
34
+ }
35
+
36
+ export const restoreChatSessionViewSnapshot = (snapshot?: ChatSessionViewSnapshot) => {
37
+ const resolved = createChatSessionViewSnapshot(snapshot)
38
+ const restorable = resolved.isHydrated === true
39
+ ? resolved
40
+ : createChatSessionViewSnapshot()
41
+
42
+ return {
43
+ messages: restorable.messages,
44
+ sessionInfo: restorable.sessionInfo,
45
+ errorState: restorable.errorState,
46
+ interactionRequest: restorable.interactionRequest,
47
+ isReady: restorable.isHydrated
48
+ }
49
+ }
50
+
51
+ export const setChatSessionViewSnapshot = (
52
+ cache: Map<string, ChatSessionViewSnapshot>,
53
+ sessionId: string,
54
+ patch: Partial<ChatSessionViewSnapshot>
55
+ ) => {
56
+ const next = mergeChatSessionViewSnapshot(cache.get(sessionId), patch)
57
+
58
+ if (cache.has(sessionId)) {
59
+ cache.delete(sessionId)
60
+ }
61
+
62
+ cache.set(sessionId, next)
63
+
64
+ while (cache.size > MAX_CHAT_SESSION_VIEW_SNAPSHOTS) {
65
+ const oldestSessionId = cache.keys().next().value
66
+ if (oldestSessionId == null) {
67
+ break
68
+ }
69
+ cache.delete(oldestSessionId)
70
+ }
71
+
72
+ return next
73
+ }
74
+
75
+ export const deleteChatSessionViewSnapshot = (
76
+ cache: Map<string, ChatSessionViewSnapshot>,
77
+ sessionId: string
78
+ ) => {
79
+ cache.delete(sessionId)
80
+ }
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { SetStateAction } from 'react'
2
3
  import { useTranslation } from 'react-i18next'
3
4
  import { useSWRConfig } from 'swr'
4
5
 
@@ -8,13 +9,19 @@ import type { SessionInfo } from '@vibe-forge/types'
8
9
  import { getSessionMessages } from '#~/api.js'
9
10
  import { connectionManager } from '#~/connectionManager.js'
10
11
 
11
- import type { ChatErrorState } from './interaction-state'
12
+ import type { ChatErrorState, InteractionRequestState } from './interaction-state'
12
13
  import {
13
14
  applyInteractionStateEvent,
14
15
  findLatestFatalError,
15
16
  getFatalSessionError,
16
17
  restoreInteractionStateFromHistory
17
18
  } from './interaction-state'
19
+ import {
20
+ deleteChatSessionViewSnapshot,
21
+ restoreChatSessionViewSnapshot,
22
+ setChatSessionViewSnapshot
23
+ } from './session-view-cache'
24
+ import type { ChatSessionViewSnapshot } from './session-view-cache'
18
25
  import type { ChatEffort } from './use-chat-effort'
19
26
  import type { PermissionMode } from './use-chat-permission-mode'
20
27
 
@@ -62,7 +69,7 @@ export function useChatSessionMessages({
62
69
  }) {
63
70
  const { t } = useTranslation()
64
71
  const { mutate } = useSWRConfig()
65
- const [messages, setMessages] = useState<ChatMessage[]>([])
72
+ const [messagesState, setMessagesState] = useState<ChatMessage[]>([])
66
73
  const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null)
67
74
  const [isReady, setIsReady] = useState(false)
68
75
  const [errorState, setErrorState] = useState<ChatErrorState | null>(null)
@@ -74,13 +81,50 @@ export function useChatSessionMessages({
74
81
  const lastConnectedAdapterRef = useRef<string | undefined>(undefined)
75
82
  const lastObservedSessionStatusRef = useRef<Session['status'] | undefined>(session?.status)
76
83
  const expectedCloseRef = useRef(false)
77
- const interactionRequestRef = useRef<{ id: string; payload: AskUserQuestionParams } | null>(null)
84
+ const interactionRequestRef = useRef<InteractionRequestState | null>(null)
78
85
  const activeSessionIdRef = useRef<string | undefined>(session?.id)
79
86
  const historyRequestSeqRef = useRef(0)
80
87
  const reconcileTimersRef = useRef<Array<ReturnType<typeof setTimeout>>>([])
88
+ const sessionViewCacheRef = useRef(new Map<string, ChatSessionViewSnapshot>())
81
89
 
82
90
  activeSessionIdRef.current = session?.id
83
91
 
92
+ const updateSessionViewCache = useCallback((
93
+ sessionId: string,
94
+ patch: Partial<{
95
+ messages: ChatMessage[]
96
+ sessionInfo: SessionInfo | null
97
+ errorState: ChatErrorState | null
98
+ interactionRequest: InteractionRequestState | null
99
+ isHydrated: boolean
100
+ }>
101
+ ) => {
102
+ return setChatSessionViewSnapshot(sessionViewCacheRef.current, sessionId, patch)
103
+ }, [])
104
+
105
+ const removeSessionViewCache = useCallback((sessionId: string) => {
106
+ deleteChatSessionViewSnapshot(sessionViewCacheRef.current, sessionId)
107
+ }, [])
108
+
109
+ const setMessages = useCallback((value: SetStateAction<ChatMessage[]>) => {
110
+ setMessagesState((current) => {
111
+ const next = typeof value === 'function'
112
+ ? value(current)
113
+ : value
114
+ const sessionId = activeSessionIdRef.current
115
+
116
+ if (sessionId != null && sessionId !== '') {
117
+ const currentSnapshot = sessionViewCacheRef.current.get(sessionId)
118
+ updateSessionViewCache(sessionId, {
119
+ messages: next,
120
+ isHydrated: currentSnapshot?.isHydrated === true
121
+ })
122
+ }
123
+
124
+ return next
125
+ })
126
+ }, [updateSessionViewCache])
127
+
84
128
  const clearScheduledReconciles = useCallback(() => {
85
129
  for (const timer of reconcileTimersRef.current) {
86
130
  clearTimeout(timer)
@@ -123,18 +167,17 @@ export function useChatSessionMessages({
123
167
  res.session?.status
124
168
  )
125
169
  const latestFatalError = findLatestFatalError(events)
170
+ const nextErrorState = restoredInteraction == null && res.session?.status === 'failed' && latestFatalError != null
171
+ ? {
172
+ kind: 'session' as const,
173
+ message: latestFatalError.message,
174
+ code: latestFatalError.code
175
+ }
176
+ : null
126
177
 
127
178
  interactionRequestRef.current = restoredInteraction
128
179
  setInteractionRequest(restoredInteraction)
129
- setErrorState(
130
- restoredInteraction == null && res.session?.status === 'failed' && latestFatalError != null
131
- ? {
132
- kind: 'session',
133
- message: latestFatalError.message,
134
- code: latestFatalError.code
135
- }
136
- : null
137
- )
180
+ setErrorState(nextErrorState)
138
181
 
139
182
  for (const data of events) {
140
183
  currentMessages = applyMessageEvent(currentMessages, data)
@@ -146,6 +189,14 @@ export function useChatSessionMessages({
146
189
  }
147
190
  }
148
191
 
192
+ updateSessionViewCache(sessionId, {
193
+ messages: currentMessages,
194
+ sessionInfo: currentSessionInfo,
195
+ errorState: nextErrorState,
196
+ interactionRequest: restoredInteraction,
197
+ isHydrated: true
198
+ })
199
+
149
200
  setMessages(currentMessages)
150
201
  setSessionInfo(currentSessionInfo)
151
202
 
@@ -161,7 +212,7 @@ export function useChatSessionMessages({
161
212
  } catch (err) {
162
213
  console.error('Failed to fetch history messages:', err)
163
214
  }
164
- }, [mutate, setInteractionRequest])
215
+ }, [mutate, setInteractionRequest, setMessages, updateSessionViewCache])
165
216
 
166
217
  const reconcileAfterInteraction = useCallback(() => {
167
218
  clearScheduledReconciles()
@@ -178,21 +229,20 @@ export function useChatSessionMessages({
178
229
  if (session?.id == null || session.id === '') return
179
230
  expectedCloseRef.current = true
180
231
  setErrorState(null)
232
+ updateSessionViewCache(session.id, { errorState: null })
181
233
  connectionManager.close(session.id)
182
234
  setRetryCount((count) => count + 1)
183
- }, [session?.id])
235
+ }, [session?.id, updateSessionViewCache])
184
236
 
185
237
  useEffect(() => {
186
- setMessages([])
187
- setSessionInfo(null)
188
- setIsReady(false)
189
- setErrorState(null)
190
- setInteractionRequest(null)
191
- interactionRequestRef.current = null
192
- isInitialLoadRef.current = true
193
-
194
238
  if (session?.id == null || session.id === '') {
239
+ setMessagesState([])
240
+ setSessionInfo(null)
195
241
  setIsReady(true)
242
+ setErrorState(null)
243
+ setInteractionRequest(null)
244
+ interactionRequestRef.current = null
245
+ isInitialLoadRef.current = true
196
246
  lastConnectedModelRef.current = undefined
197
247
  lastConnectedEffortRef.current = undefined
198
248
  lastConnectedPermissionModeRef.current = undefined
@@ -201,6 +251,16 @@ export function useChatSessionMessages({
201
251
  return
202
252
  }
203
253
 
254
+ const restoredState = restoreChatSessionViewSnapshot(sessionViewCacheRef.current.get(session.id))
255
+
256
+ setMessagesState(restoredState.messages)
257
+ setSessionInfo(restoredState.sessionInfo)
258
+ setErrorState(restoredState.errorState)
259
+ setInteractionRequest(restoredState.interactionRequest)
260
+ interactionRequestRef.current = restoredState.interactionRequest
261
+ setIsReady(restoredState.isReady)
262
+ isInitialLoadRef.current = !restoredState.isReady
263
+
204
264
  void refreshHistory()
205
265
 
206
266
  return () => {
@@ -281,7 +341,13 @@ export function useChatSessionMessages({
281
341
  cleanup = connectionManager.connect(session.id, {
282
342
  onOpen() {
283
343
  expectedCloseRef.current = false
284
- setErrorState((current) => current?.kind === 'session' ? current : null)
344
+ setErrorState((current) => {
345
+ const next = current?.kind === 'session' ? current : null
346
+ updateSessionViewCache(session.id, {
347
+ errorState: next
348
+ })
349
+ return next
350
+ })
285
351
  },
286
352
  onMessage(data: WSEvent) {
287
353
  if (isDisposed) return
@@ -289,8 +355,14 @@ export function useChatSessionMessages({
289
355
  if (nextInteraction !== interactionRequestRef.current) {
290
356
  interactionRequestRef.current = nextInteraction
291
357
  setInteractionRequest(nextInteraction)
358
+ updateSessionViewCache(session.id, {
359
+ interactionRequest: nextInteraction
360
+ })
292
361
  if (nextInteraction != null) {
293
362
  setErrorState(null)
363
+ updateSessionViewCache(session.id, {
364
+ errorState: null
365
+ })
294
366
  }
295
367
  }
296
368
  if (data.type === 'interaction_response') {
@@ -300,10 +372,14 @@ export function useChatSessionMessages({
300
372
  if (data.type === 'error') {
301
373
  const fatalError = getFatalSessionError(data)
302
374
  if (fatalError != null) {
303
- setErrorState({
375
+ const nextErrorState = {
304
376
  kind: 'session',
305
377
  message: fatalError.message,
306
378
  code: fatalError.code
379
+ } satisfies ChatErrorState
380
+ setErrorState(nextErrorState)
381
+ updateSessionViewCache(session.id, {
382
+ errorState: nextErrorState
307
383
  })
308
384
  }
309
385
  return
@@ -315,6 +391,7 @@ export function useChatSessionMessages({
315
391
  const updatedSession = data.session as Session | { id: string; isDeleted: boolean }
316
392
 
317
393
  if ('isDeleted' in updatedSession && updatedSession.isDeleted) {
394
+ removeSessionViewCache(updatedSession.id)
318
395
  return {
319
396
  ...prev,
320
397
  sessions: prev.sessions.filter((s: Session) => s.id !== updatedSession.id)
@@ -347,6 +424,9 @@ export function useChatSessionMessages({
347
424
  void mutate('/api/sessions')
348
425
  } else {
349
426
  setSessionInfo(data.info ?? null)
427
+ updateSessionViewCache(session.id, {
428
+ sessionInfo: data.info ?? null
429
+ })
350
430
  if (isInitialLoadRef.current) {
351
431
  setTimeout(() => {
352
432
  if (isDisposed) return
@@ -366,15 +446,23 @@ export function useChatSessionMessages({
366
446
  }
367
447
 
368
448
  if (data.type === 'interaction_request') {
449
+ interactionRequestRef.current = data
369
450
  setInteractionRequest(data)
451
+ updateSessionViewCache(session.id, {
452
+ interactionRequest: data
453
+ })
370
454
  }
371
455
  },
372
456
  onError() {
373
457
  if (isDisposed) return
374
- setErrorState({
458
+ const nextErrorState = {
375
459
  kind: 'connection',
376
460
  message: t('chat.connectionError'),
377
461
  reason: 'error'
462
+ } satisfies ChatErrorState
463
+ setErrorState(nextErrorState)
464
+ updateSessionViewCache(session.id, {
465
+ errorState: nextErrorState
378
466
  })
379
467
  },
380
468
  onClose() {
@@ -383,13 +471,17 @@ export function useChatSessionMessages({
383
471
  expectedCloseRef.current = false
384
472
  return
385
473
  }
386
- setErrorState((current) =>
387
- current ?? {
474
+ setErrorState((current) => {
475
+ const next = current ?? {
388
476
  kind: 'connection',
389
477
  message: t('chat.connectionClosed'),
390
478
  reason: 'closed'
391
479
  }
392
- )
480
+ updateSessionViewCache(session.id, {
481
+ errorState: next
482
+ })
483
+ return next
484
+ })
393
485
  }
394
486
  }, Object.keys(connectionParams).length > 0 ? connectionParams : undefined)
395
487
  }, (modelChanged || effortChanged || permissionModeChanged || adapterChanged) ? 200 : 100)
@@ -412,11 +504,13 @@ export function useChatSessionMessages({
412
504
  session?.id,
413
505
  session?.status,
414
506
  setInteractionRequest,
415
- t
507
+ t,
508
+ removeSessionViewCache,
509
+ updateSessionViewCache
416
510
  ])
417
511
 
418
512
  return {
419
- messages,
513
+ messages: messagesState,
420
514
  setMessages,
421
515
  sessionInfo,
422
516
  isReady,