@nextclaw/ui 0.15.5 → 0.15.7

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 (169) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/api-V8m62-13.js +13 -0
  3. package/dist/assets/app-presenter-provider-Bzj9h_ZS.js +106 -0
  4. package/dist/assets/{appearance-settings-page-80fLa4-9.js → appearance-settings-page-DwBwu1AU.js} +1 -1
  5. package/dist/assets/{book-open-CN7Shpij.js → book-open-DtVSr5O9.js} +1 -1
  6. package/dist/assets/{channels-list-page-R7M6pzp6.js → channels-list-page-CYIqMliJ.js} +2 -2
  7. package/dist/assets/{chat-page-152YuwfU.js → chat-page-BC0icTtI.js} +1 -1
  8. package/dist/assets/{config-split-page-BlfvfmTm.js → config-split-page-B88prd8s.js} +1 -1
  9. package/dist/assets/{confirm-dialog-Dt8Kdbgp.js → confirm-dialog-BB09IC2u.js} +1 -1
  10. package/dist/assets/desktop-update-config-B-q0OhxO.js +1 -0
  11. package/dist/assets/{dist-D5ycbwCo.js → dist-Bytser-a.js} +1 -1
  12. package/dist/assets/{doc-browser-C9u7N9vH.js → doc-browser-B1FXvZm6.js} +1 -1
  13. package/dist/assets/doc-browser-Bodhf0fj2.js +1 -0
  14. package/dist/assets/{doc-browser-context-CCdW9TJf.js → doc-browser-context-D5VLNoj3.js} +1 -1
  15. package/dist/assets/{download-CyPgglKr.js → download-CdO6NoHj.js} +1 -1
  16. package/dist/assets/{external-link-BahW1sl3.js → external-link-C5NFoh5I.js} +1 -1
  17. package/dist/assets/{eye-D8gGXMkD.js → eye-B51oNRRc.js} +1 -1
  18. package/dist/assets/{folder-DGWqFDvE.js → folder-DxH7Nef-.js} +1 -1
  19. package/dist/assets/{form-actions-D2imZLnU.js → form-actions-C9pUwtYV.js} +1 -1
  20. package/dist/assets/index-DtWf7kk9.css +1 -0
  21. package/dist/assets/index-UnY4HtVa.js +2 -0
  22. package/dist/assets/loader-circle-BRSfaztA.js +1 -0
  23. package/dist/assets/{logo-badge-CuPetu-v.js → logo-badge-D2vORX3a.js} +1 -1
  24. package/dist/assets/mcp-marketplace-page-CGVu7mfL.js +9 -0
  25. package/dist/assets/mcp-marketplace-page-DnV0e1JS.js +1 -0
  26. package/dist/assets/{model-config-page-DiJcvBML.js → model-config-page-DYpchGVE.js} +1 -1
  27. package/dist/assets/plus-BgMBV9HJ.js +1 -0
  28. package/dist/assets/{popover-Btp7KVc3.js → popover-CONujDHf.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-DZMtoAJG.js → provider-scoped-model-input-DA3Ua9v8.js} +1 -1
  30. package/dist/assets/providers-config-page-DXsoyMrh.js +1 -0
  31. package/dist/assets/{react-BY-U3h3t.js → react-Io6Pfq8D.js} +1 -1
  32. package/dist/assets/{refresh-cw-Cme5qFej.js → refresh-cw-C6j2tjul.js} +1 -1
  33. package/dist/assets/remote-C1y3ARHu.js +1 -0
  34. package/dist/assets/rotate-cw-Bv2B6fAV.js +1 -0
  35. package/dist/assets/runtime-config-page-eYrrKBrA.js +1 -0
  36. package/dist/assets/{save-DNg2yJTS.js → save-CBBZVvZ-.js} +1 -1
  37. package/dist/assets/{search-3ykfrAFt.js → search-DFF842z2.js} +1 -1
  38. package/dist/assets/{search-config-page-DrVKa_Ls.js → search-config-page-DUyuwD7d.js} +1 -1
  39. package/dist/assets/{secrets-config-page-B39uQ5kF.js → secrets-config-page-DwzKQJ76.js} +2 -2
  40. package/dist/assets/security-config-D8me3pXl.js +1 -0
  41. package/dist/assets/{select-B5EecXJt.js → select-BiOpo2kV.js} +1 -1
  42. package/dist/assets/{setting-row-Qv1Ps79v.js → setting-row-C3WQXLtS.js} +1 -1
  43. package/dist/assets/{settings-2-D-3um9Az.js → settings-2-DyOvA_6K.js} +1 -1
  44. package/dist/assets/{skeleton-BgkzQd4U.js → skeleton-CQIAEbA4.js} +1 -1
  45. package/dist/assets/{tabs-BVD8gp3y.js → tabs-BTSENlI2.js} +1 -1
  46. package/dist/assets/{tag-chip-0qn-f3Bo.js → tag-chip-BG5JIGwY.js} +1 -1
  47. package/dist/assets/{trash-2-cnsMppRC.js → trash-2-CTIfwpt1.js} +1 -1
  48. package/dist/assets/use-config-CY4uF2Ou.js +1 -0
  49. package/dist/assets/{use-confirm-dialog-C_o0eQeH.js → use-confirm-dialog-Bj4u78F9.js} +1 -1
  50. package/dist/assets/{use-viewport-layout-BWav4KCL.js → use-viewport-layout-ChJUcSQ6.js} +1 -1
  51. package/dist/assets/x-C6cM8wkT.js +1 -0
  52. package/dist/index.html +30 -29
  53. package/index.html +1 -0
  54. package/package.json +8 -8
  55. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +97 -3
  56. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +13 -2
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar-test.utils.ts +22 -0
  58. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +13 -24
  59. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -4
  60. package/src/features/chat/components/layout/chat-sidebar.tsx +38 -2
  61. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -0
  62. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +70 -2
  63. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +22 -0
  64. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +8 -9
  65. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-input-state.test.tsx +55 -0
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +12 -11
  67. package/src/features/chat/features/conversation/hooks/use-session-conversation-preference-actions.ts +94 -0
  68. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +82 -0
  69. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +187 -63
  70. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +225 -0
  71. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +23 -0
  72. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +6 -1
  73. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +357 -0
  74. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +4 -1
  75. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +26 -0
  76. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +67 -5
  77. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +4 -10
  78. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +29 -0
  79. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +83 -8
  80. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +1 -0
  81. package/src/features/chat/features/message/components/__tests__/chat-inline-file-preview.test.tsx +159 -0
  82. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +92 -26
  83. package/src/features/chat/features/message/components/chat-inline-file-preview.tsx +90 -21
  84. package/src/features/chat/features/message/components/chat-message-list.container.tsx +24 -3
  85. package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +1 -0
  86. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +96 -3
  87. package/src/features/chat/features/project/components/__tests__/chat-project-create-dialog.test.tsx +67 -0
  88. package/src/features/chat/features/project/components/chat-project-create-dialog.tsx +203 -0
  89. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +7 -7
  90. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-dialog.test.tsx +47 -0
  91. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +6 -3
  92. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +3 -0
  93. package/src/features/chat/features/session/components/session-header/chat-session-project-dialog.tsx +3 -0
  94. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +245 -0
  95. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +82 -128
  96. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +22 -0
  97. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +6 -6
  98. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +12 -1
  99. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +3 -0
  100. package/src/features/chat/features/welcome/components/chat-welcome.tsx +1 -0
  101. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +20 -0
  102. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +15 -2
  103. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +5 -6
  104. package/src/features/chat/features/workspace/components/__tests__/workspace-file-content-preview.test.tsx +67 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +9 -7
  106. package/src/features/chat/features/workspace/components/workspace-file-content-preview.tsx +95 -4
  107. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +23 -0
  108. package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +1 -0
  109. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +62 -0
  110. package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
  111. package/src/features/marketplace/components/curated-shelves/marketplace-curated-shelves.tsx +2 -15
  112. package/src/features/marketplace/components/marketplace-page.tsx +10 -2
  113. package/src/features/marketplace/hooks/__tests__/use-marketplace.test.tsx +26 -1
  114. package/src/features/marketplace/hooks/use-marketplace-curated-scene-route.ts +11 -0
  115. package/src/features/marketplace/hooks/use-marketplace-list-model.ts +1 -1
  116. package/src/features/marketplace/hooks/use-marketplace.ts +28 -1
  117. package/src/features/settings/components/config/provider-form.tsx +0 -4
  118. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +7 -9
  119. package/src/features/settings/utils/provider-form-model.utils.ts +0 -4
  120. package/src/features/system-status/hooks/use-current-version-release-notes-link.ts +33 -0
  121. package/src/features/system-status/index.ts +9 -0
  122. package/src/features/system-status/utils/update-release-notes.utils.ts +44 -3
  123. package/src/index.css +10 -16
  124. package/src/shared/components/common/__tests__/brand-header.test.tsx +110 -3
  125. package/src/shared/components/common/brand-header.tsx +121 -35
  126. package/src/shared/components/path-picker/__tests__/server-path-picker-dialog.test.tsx +288 -38
  127. package/src/shared/components/path-picker/server-path-picker-dialog.tsx +290 -199
  128. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +103 -0
  129. package/src/shared/components/path-picker/server-path-picker-footer.tsx +58 -0
  130. package/src/shared/components/path-picker/server-path-picker-locations.tsx +148 -0
  131. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +198 -0
  132. package/src/shared/components/path-picker/server-path-picker-new-folder.tsx +59 -0
  133. package/src/shared/components/path-picker/server-path-picker-toolbar.tsx +25 -0
  134. package/src/shared/hooks/use-projects.ts +28 -0
  135. package/src/shared/hooks/use-server-path-create-directory.ts +6 -0
  136. package/src/shared/hooks/use-server-path-search.ts +40 -0
  137. package/src/shared/lib/api/index.ts +1 -0
  138. package/src/shared/lib/api/projects/project.utils.ts +14 -0
  139. package/src/shared/lib/api/types.ts +27 -0
  140. package/src/shared/lib/api/utils/server-path.utils.ts +21 -1
  141. package/src/shared/lib/i18n/locales/en-US/chat.json +40 -2
  142. package/src/shared/lib/i18n/locales/en-US/core.json +2 -3
  143. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +5 -1
  144. package/src/shared/lib/i18n/locales/en-US/marketplace.json +1 -0
  145. package/src/shared/lib/i18n/locales/en-US/path-picker.json +25 -0
  146. package/src/shared/lib/i18n/locales/zh-CN/chat.json +40 -2
  147. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -3
  148. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +5 -1
  149. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +1 -0
  150. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +25 -0
  151. package/dist/assets/api-IlX_CbwU.js +0 -13
  152. package/dist/assets/app-presenter-provider-Bbl_MVbg.js +0 -106
  153. package/dist/assets/desktop-update-config-DreMJu9z.js +0 -1
  154. package/dist/assets/doc-browser-DJQ5XPsi2.js +0 -1
  155. package/dist/assets/index-0av6WO9-.css +0 -1
  156. package/dist/assets/index-CT0iYlxV.js +0 -2
  157. package/dist/assets/loader-circle-DtKek-qG.js +0 -1
  158. package/dist/assets/mcp-marketplace-page-4qMwFDpz.js +0 -1
  159. package/dist/assets/mcp-marketplace-page-BbxN5aZF.js +0 -9
  160. package/dist/assets/plus-BwgE-Zwf.js +0 -1
  161. package/dist/assets/providers-config-page-DfiWveEF.js +0 -1
  162. package/dist/assets/remote-BaVECEZw.js +0 -1
  163. package/dist/assets/rotate-cw-CMRZxIkH.js +0 -1
  164. package/dist/assets/runtime-config-page-wygwvMB4.js +0 -1
  165. package/dist/assets/security-config-BPzet76z.js +0 -1
  166. package/dist/assets/use-config-D6yx0lb0.js +0 -1
  167. package/dist/assets/x-B3o1dWR5.js +0 -1
  168. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +0 -77
  169. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +0 -144
@@ -1,33 +1,21 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import {
3
- Check,
4
3
  ChevronDown,
5
4
  Loader2,
6
5
  MessageSquareText,
7
6
  Search,
8
7
  } from "lucide-react";
9
8
  import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
9
+ import { useNcpSessionListView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
10
10
  import {
11
- useNcpSessionListView,
12
- type NcpSessionListItemView,
13
- } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
14
- import { SessionContextIconNode } from "@/features/chat/features/session/components/session-context-icon";
15
- import { SessionRunBadge } from "@/features/chat/features/session/components/session-run-badge";
16
- import {
17
- formatSessionListTime,
18
- sessionActivityPreviewText,
19
- sessionDisplayName,
20
- } from "@/features/chat/features/session/utils/chat-session-display.utils";
21
- import { sortSessionItemsByActivityAtDesc } from "@/features/chat/features/session/utils/chat-sidebar-session-groups.utils";
22
- import { resolveSessionContextView } from "@/features/chat/features/session/utils/session-context.utils";
23
- import {
24
- buildSessionTypeOptions,
25
- type ChatSessionTypeOption,
26
- } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
27
- import {
28
- shouldShowUnreadSessionIndicator,
29
- useChatSessionListStore,
30
- } from "@/features/chat/stores/chat-session-list.store";
11
+ groupSessionsByDate,
12
+ groupSessionsByProject,
13
+ sortSessionItemsByActivityAtDesc,
14
+ } from "@/features/chat/features/session/utils/chat-sidebar-session-groups.utils";
15
+ import { ChatSidebarListModeSwitch } from "@/features/chat/components/chat-sidebar-list-mode-switch";
16
+ import { buildSessionTypeOptions } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
17
+ import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
18
+ import { ChatSessionSwitcherGroups } from "@/features/chat/features/session/components/session-header/chat-session-switcher-groups";
31
19
  import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
32
20
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
33
21
  import {
@@ -38,6 +26,7 @@ import { ChatPopoverContent } from "@/features/chat/components/chat-popover-cont
38
26
  import { Input } from "@/shared/components/ui/input";
39
27
  import { t } from "@/shared/lib/i18n";
40
28
  import { cn } from "@/shared/lib/utils";
29
+ import { useProjects } from "@/shared/hooks/use-projects";
41
30
 
42
31
  const SWITCHER_TRIGGER_CLASS =
43
32
  "group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border";
@@ -68,96 +57,6 @@ function ChatSessionTitleSwitcherLoadingState() {
68
57
  );
69
58
  }
70
59
 
71
- function ChatSessionSwitchItem({
72
- item,
73
- selectedSessionKey,
74
- optimisticReadAtBySessionKey,
75
- sessionTypeOptions,
76
- onSelect,
77
- }: {
78
- item: NcpSessionListItemView;
79
- selectedSessionKey: string | null;
80
- optimisticReadAtBySessionKey: Record<string, string>;
81
- sessionTypeOptions: ChatSessionTypeOption[];
82
- onSelect: (sessionKey: string) => void;
83
- }) {
84
- const { session, runStatus } = item;
85
- const active = selectedSessionKey === session.key;
86
- const optimisticReadAt = optimisticReadAtBySessionKey[session.key];
87
- const effectiveReadAt =
88
- optimisticReadAt && session.readAt
89
- ? optimisticReadAt.localeCompare(session.readAt) > 0
90
- ? optimisticReadAt
91
- : session.readAt
92
- : (optimisticReadAt ?? session.readAt);
93
- const shouldShowUnread = shouldShowUnreadSessionIndicator({
94
- active,
95
- lastMessageAt: session.lastMessageAt,
96
- readAt: effectiveReadAt,
97
- runStatus,
98
- });
99
- const previewText =
100
- sessionActivityPreviewText(session) ??
101
- session.projectName ??
102
- session.projectRoot ??
103
- `${session.messageCount}`;
104
- const trailingText = formatSessionListTime(
105
- session.lastMessageAt ?? session.createdAt,
106
- );
107
- const sessionContext = resolveSessionContextView(session, sessionTypeOptions);
108
-
109
- return (
110
- <button
111
- type="button"
112
- aria-current={active ? "page" : undefined}
113
- className={cn(
114
- "flex w-full min-w-0 items-start gap-2 rounded-lg px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
115
- active
116
- ? "bg-accent text-accent-foreground"
117
- : "text-popover-foreground hover:bg-accent/70",
118
- )}
119
- onClick={() => onSelect(session.key)}
120
- >
121
- <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
122
- {active ? (
123
- <Check className="h-3.5 w-3.5" />
124
- ) : shouldShowUnread ? (
125
- <span
126
- aria-label={t("chatSessionUnread")}
127
- className="h-2 w-2 rounded-full bg-primary"
128
- />
129
- ) : null}
130
- </span>
131
- <span className="min-w-0 flex-1">
132
- <span className="flex min-w-0 items-center gap-1.5">
133
- <span className="min-w-0 truncate text-[13px] font-medium">
134
- {sessionDisplayName(session)}
135
- </span>
136
- {sessionContext.label ? (
137
- <span className="shrink-0 rounded-full border border-border/70 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
138
- {sessionContext.label}
139
- </span>
140
- ) : null}
141
- {sessionContext.icon ? (
142
- <span className="inline-flex h-[1.125rem] w-[1.125rem] shrink-0 items-center justify-center text-muted-foreground">
143
- <SessionContextIconNode icon={sessionContext.icon} />
144
- </span>
145
- ) : null}
146
- </span>
147
- <span className="mt-0.5 flex min-w-0 items-center gap-2 text-[11px] text-muted-foreground">
148
- <span className="min-w-0 truncate">{previewText}</span>
149
- <span className="shrink-0">{trailingText}</span>
150
- </span>
151
- </span>
152
- {runStatus ? (
153
- <span className="mt-0.5 shrink-0">
154
- <SessionRunBadge status={runStatus} />
155
- </span>
156
- ) : null}
157
- </button>
158
- );
159
- }
160
-
161
60
  function ChatSessionTitleSwitcherPopover({
162
61
  selectedSessionKey,
163
62
  title,
@@ -171,14 +70,51 @@ function ChatSessionTitleSwitcherPopover({
171
70
  const optimisticReadAtBySessionKey = useChatSessionListStore(
172
71
  (state) => state.optimisticReadAtBySessionKey,
173
72
  );
73
+ const pinnedSessionKeys = useChatSessionListStore(
74
+ (state) => state.snapshot.pinnedSessionKeys,
75
+ );
76
+ const pinnedProjectRoots = useChatSessionListStore(
77
+ (state) => state.snapshot.pinnedProjectRoots,
78
+ );
79
+ const collapsedProjectRoots = useChatSessionListStore(
80
+ (state) => state.snapshot.collapsedProjectRoots,
81
+ );
82
+ const listMode = useChatSessionListStore(
83
+ (state) => state.snapshot.listMode,
84
+ );
174
85
  const sessionTypesData = useChatQueryStore(
175
86
  (state) => state.snapshot.sessionTypesQuery?.data ?? null,
176
87
  );
177
88
  const { isLoading, items } = useNcpSessionListView({ query: searchQuery });
89
+ const projectsQuery = useProjects();
90
+ const visibleProjects = useMemo(() => {
91
+ const query = searchQuery.trim().toLowerCase();
92
+ const projects = projectsQuery.data?.projects ?? [];
93
+ if (!query) {
94
+ return projects;
95
+ }
96
+ return projects.filter((project) =>
97
+ project.name.toLowerCase().includes(query) ||
98
+ project.rootPath.toLowerCase().includes(query)
99
+ );
100
+ }, [projectsQuery.data?.projects, searchQuery]);
178
101
  const sortedItems = useMemo(
179
102
  () => sortSessionItemsByActivityAtDesc(items),
180
103
  [items],
181
104
  );
105
+ const dateGroups = useMemo(
106
+ () => groupSessionsByDate(sortedItems, new Set(pinnedSessionKeys)),
107
+ [pinnedSessionKeys, sortedItems],
108
+ );
109
+ const projectGroups = useMemo(
110
+ () => groupSessionsByProject(
111
+ sortedItems,
112
+ new Set(pinnedSessionKeys),
113
+ new Set(pinnedProjectRoots),
114
+ visibleProjects,
115
+ ),
116
+ [pinnedProjectRoots, pinnedSessionKeys, sortedItems, visibleProjects],
117
+ );
182
118
  const sessionTypeOptions = useMemo(
183
119
  () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
184
120
  [sessionTypesData?.options],
@@ -198,6 +134,16 @@ function ChatSessionTitleSwitcherPopover({
198
134
  setSearchQuery("");
199
135
  }
200
136
  };
137
+ const sessionListProps = {
138
+ selectedSessionKey,
139
+ optimisticReadAtBySessionKey,
140
+ sessionTypeOptions,
141
+ onSelect: selectSession,
142
+ };
143
+ const isProjectFirstView = listMode === "project-first";
144
+ const hasVisibleGroups = isProjectFirstView
145
+ ? projectGroups.length > 0
146
+ : dateGroups.length > 0;
201
147
 
202
148
  return (
203
149
  <Popover open={isOpen} onOpenChange={updateOpen}>
@@ -222,8 +168,14 @@ function ChatSessionTitleSwitcherPopover({
222
168
  className="w-[22rem] max-w-[calc(100vw-2rem)] p-0"
223
169
  >
224
170
  <div className="space-y-2 border-b border-border px-3 py-2">
225
- <div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/75">
226
- {t("chatSessionSwitcherTitle")}
171
+ <div className="flex items-center justify-between gap-3">
172
+ <div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/75">
173
+ {t("chatSessionSwitcherTitle")}
174
+ </div>
175
+ <ChatSidebarListModeSwitch
176
+ isProjectFirstView={isProjectFirstView}
177
+ onSelectMode={presenter.chatSessionListManager.setListMode}
178
+ />
227
179
  </div>
228
180
  <div className="relative">
229
181
  <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/65" />
@@ -240,25 +192,27 @@ function ChatSessionTitleSwitcherPopover({
240
192
  <div className="max-h-80 overflow-y-auto p-1.5">
241
193
  {isLoading ? (
242
194
  <ChatSessionTitleSwitcherLoadingState />
243
- ) : sortedItems.length > 0 ? (
244
- <div className="space-y-1">
245
- {sortedItems.map((item) => (
246
- <ChatSessionSwitchItem
247
- key={item.session.key}
248
- item={item}
249
- selectedSessionKey={selectedSessionKey}
250
- optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
251
- sessionTypeOptions={sessionTypeOptions}
252
- onSelect={selectSession}
253
- />
254
- ))}
255
- </div>
195
+ ) : hasVisibleGroups ? (
196
+ <ChatSessionSwitcherGroups
197
+ collapsedProjectRoots={collapsedProjectRoots}
198
+ dateGroups={dateGroups}
199
+ isProjectFirstView={isProjectFirstView}
200
+ onToggleProjectCollapsed={
201
+ presenter.chatSessionListManager.toggleProjectCollapsed
202
+ }
203
+ projectGroups={projectGroups}
204
+ {...sessionListProps}
205
+ />
256
206
  ) : (
257
207
  <ChatSessionTitleSwitcherEmptyState
258
208
  label={
259
209
  searchQuery.trim()
260
210
  ? t("chatSessionSwitcherNoResults")
261
- : t("sessionsEmpty")
211
+ : t(
212
+ isProjectFirstView
213
+ ? "chatSidebarProjectViewEmpty"
214
+ : "sessionsEmpty",
215
+ )
262
216
  }
263
217
  />
264
218
  )}
@@ -281,7 +235,7 @@ export function ChatSessionTitleSwitcher({
281
235
  (state) => state.isSidebarCollapsed,
282
236
  );
283
237
 
284
- if (layoutMode !== "desktop" || !isSidebarCollapsed) {
238
+ if (layoutMode === "desktop" && !isSidebarCollapsed) {
285
239
  return <ChatSessionTitle title={title} />;
286
240
  }
287
241
 
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { groupSessionsByProject } from "@/features/chat/features/session/utils/chat-sidebar-session-groups.utils";
3
+
4
+ describe("groupSessionsByProject", () => {
5
+ it("uses registered projects as the project-first baseline", () => {
6
+ const groups = groupSessionsByProject([], new Set(), new Set(), [{
7
+ name: "Knowledge",
8
+ rootPath: "/tmp/knowledge",
9
+ template: "knowledge-base",
10
+ createdAt: "2026-07-15T00:00:00.000Z",
11
+ updatedAt: "2026-07-15T00:00:00.000Z",
12
+ }]);
13
+
14
+ expect(groups).toEqual([{
15
+ projectRoot: "/tmp/knowledge",
16
+ projectName: "Knowledge",
17
+ items: [],
18
+ latestUpdatedAt: new Date("2026-07-15T00:00:00.000Z").getTime(),
19
+ isPinned: false,
20
+ }]);
21
+ });
22
+ });
@@ -2,7 +2,7 @@ import {
2
2
  CHAT_SESSION_MATERIALIZATION_METADATA_KEY,
3
3
  type AgentRunSessionMaterializationMetadata,
4
4
  } from "@nextclaw/shared";
5
- import { buildInlineSkillTokensFromComposer, CHAT_UI_INLINE_TOKENS_METADATA_KEY } from "@/features/chat/features/input/utils/chat-inline-token.utils";
5
+ import { buildInlineTokensFromComposer, CHAT_INLINE_TOKENS_METADATA_KEY } from "@/features/chat/features/input/utils/chat-inline-token.utils";
6
6
  import { normalizeRequestedSkills } from "@/features/chat/features/runtime/utils/chat-runtime.utils";
7
7
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
8
8
 
@@ -20,7 +20,7 @@ export function buildChatRunMetadata(payload: {
20
20
  sessionType?: string;
21
21
  projectRoot?: string | null;
22
22
  requestedSkills?: string[];
23
- composerNodes?: Parameters<typeof buildInlineSkillTokensFromComposer>[0];
23
+ composerNodes?: Parameters<typeof buildInlineTokensFromComposer>[0];
24
24
  sessionMaterialization?: AgentRunSessionMaterializationMetadata | null;
25
25
  }): Record<string, unknown> {
26
26
  const projectRoot = normalizeSessionProjectRootValue(payload.projectRoot);
@@ -35,11 +35,11 @@ export function buildChatRunMetadata(payload: {
35
35
  if (requestedSkills.length > 0) {
36
36
  metadata.requested_skill_refs = requestedSkills;
37
37
  }
38
- const inlineSkillTokens = payload.composerNodes
39
- ? buildInlineSkillTokensFromComposer(payload.composerNodes)
38
+ const inlineTokens = payload.composerNodes
39
+ ? buildInlineTokensFromComposer(payload.composerNodes)
40
40
  : [];
41
- if (inlineSkillTokens.length > 0) {
42
- metadata[CHAT_UI_INLINE_TOKENS_METADATA_KEY] = inlineSkillTokens;
41
+ if (inlineTokens.length > 0) {
42
+ metadata[CHAT_INLINE_TOKENS_METADATA_KEY] = inlineTokens;
43
43
  }
44
44
  if (payload.sessionMaterialization) {
45
45
  metadata[CHAT_SESSION_MATERIALIZATION_METADATA_KEY] = payload.sessionMaterialization;
@@ -1,4 +1,4 @@
1
- import type { SessionEntryView } from "@/shared/lib/api";
1
+ import type { ProjectView, SessionEntryView } from "@/shared/lib/api";
2
2
  import type { NcpSessionListItemView } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
3
3
  import { getSessionProjectName } from "@/shared/lib/session-project";
4
4
  import { t } from "@/shared/lib/i18n";
@@ -96,9 +96,20 @@ export function groupSessionsByProject(
96
96
  items: NcpSessionListItemView[],
97
97
  pinnedSessionKeys: ReadonlySet<string> = new Set(),
98
98
  pinnedProjectRoots: ReadonlySet<string> = new Set(),
99
+ projects: readonly ProjectView[] = [],
99
100
  ): ChatSidebarProjectGroup[] {
100
101
  const grouped = new Map<string, ChatSidebarProjectGroup>();
101
102
 
103
+ for (const project of projects) {
104
+ grouped.set(project.rootPath, {
105
+ projectRoot: project.rootPath,
106
+ projectName: project.name,
107
+ items: [],
108
+ latestUpdatedAt: new Date(project.updatedAt).getTime(),
109
+ isPinned: pinnedProjectRoots.has(project.rootPath),
110
+ });
111
+ }
112
+
102
113
  for (const item of items) {
103
114
  const projectRoot = item.session.projectRoot?.trim();
104
115
  if (!projectRoot) {
@@ -16,6 +16,7 @@ import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
16
16
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
17
17
  import { useChatThreadStore } from "@/features/chat/stores/chat-thread.store";
18
18
  import { useAgents } from "@/shared/hooks/use-agents";
19
+ import { useProjects } from "@/shared/hooks/use-projects";
19
20
  import type { NcpSessionSummaryView } from "@/shared/lib/api";
20
21
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
21
22
 
@@ -55,6 +56,7 @@ export function ChatConversationWelcome({
55
56
  (state) => state.snapshot.sessionTypesQuery?.data ?? null,
56
57
  );
57
58
  const agentsQuery = useAgents();
59
+ const projectsQuery = useProjects();
58
60
  const draftAgentId = threadSnapshot.agentId ?? selectedAgentId;
59
61
  const availableAgents = resolveChatWelcomeAgents({
60
62
  agents: agentsQuery.data?.agents,
@@ -80,6 +82,7 @@ export function ChatConversationWelcome({
80
82
  });
81
83
  const projectOptions = buildChatWelcomeProjectOptions({
82
84
  defaultProjectRoot,
85
+ projects: projectsQuery.data?.projects ?? [],
83
86
  sessionSummaries,
84
87
  });
85
88
  const selectDraftAgent = (agentId: string) => {
@@ -106,6 +106,7 @@ export function ChatWelcome({
106
106
  <ChatSessionProjectDialog
107
107
  open={isProjectDialogOpen}
108
108
  currentProjectRoot={resolvedProjectRoot}
109
+ defaultWorkspacePath={defaultProjectRoot}
109
110
  isSaving={isProjectSaving}
110
111
  onOpenChange={setIsProjectDialogOpen}
111
112
  onSave={saveProjectRoot}
@@ -48,4 +48,24 @@ describe("buildChatWelcomeProjectOptions", () => {
48
48
  },
49
49
  ]);
50
50
  });
51
+
52
+ it("includes a registered project before it has any sessions", () => {
53
+ const options = buildChatWelcomeProjectOptions({
54
+ defaultProjectRoot: "/Users/demo/.nextclaw/workspace",
55
+ projects: [{
56
+ name: "Knowledge",
57
+ rootPath: "/tmp/knowledge",
58
+ template: "knowledge-base",
59
+ createdAt: "2026-07-15T00:00:00.000Z",
60
+ updatedAt: "2026-07-15T00:00:00.000Z",
61
+ }],
62
+ sessionSummaries: [],
63
+ });
64
+
65
+ expect(options).toEqual([{
66
+ projectRoot: "/tmp/knowledge",
67
+ projectName: "Knowledge",
68
+ sessionCount: 0,
69
+ }]);
70
+ });
51
71
  });
@@ -1,4 +1,4 @@
1
- import type { NcpSessionSummaryView } from "@/shared/lib/api";
1
+ import type { NcpSessionSummaryView, ProjectView } from "@/shared/lib/api";
2
2
  import { adaptNcpSessionSummaries } from "@/features/chat/features/session/utils/ncp-session-adapter.utils";
3
3
  import { getSessionProjectName } from "@/shared/lib/session-project";
4
4
 
@@ -17,14 +17,27 @@ function readSessionActivityAt(session: {
17
17
 
18
18
  export function buildChatWelcomeProjectOptions(params: {
19
19
  defaultProjectRoot: string | null;
20
+ projects?: readonly ProjectView[];
20
21
  sessionSummaries: readonly NcpSessionSummaryView[];
21
22
  }): ChatWelcomeProjectOption[] {
22
- const { defaultProjectRoot, sessionSummaries } = params;
23
+ const { defaultProjectRoot, projects = [], sessionSummaries } = params;
23
24
  const groups = new Map<
24
25
  string,
25
26
  ChatWelcomeProjectOption & { latestUpdatedAt: number }
26
27
  >();
27
28
 
29
+ for (const project of projects) {
30
+ if (project.rootPath === defaultProjectRoot) {
31
+ continue;
32
+ }
33
+ groups.set(project.rootPath, {
34
+ projectRoot: project.rootPath,
35
+ projectName: project.name,
36
+ sessionCount: 0,
37
+ latestUpdatedAt: new Date(project.updatedAt).getTime(),
38
+ });
39
+ }
40
+
28
41
  for (const session of adaptNcpSessionSummaries([...sessionSummaries])) {
29
42
  const projectRoot = session.projectRoot?.trim();
30
43
  if (!projectRoot || projectRoot === defaultProjectRoot) {
@@ -20,6 +20,7 @@ const destroyPresentationMock = vi.fn();
20
20
  type RenderWorkspaceFilePreviewOptions = {
21
21
  file?: Partial<ChatWorkspaceFileTab>;
22
22
  onFileOpen?: (action: ChatFileOpenActionViewModel) => void;
23
+ onHtmlContentHeightChange?: (height: number) => void;
23
24
  refreshVersion?: number;
24
25
  sessionProjectRoot?: string | null;
25
26
  sessionWorkingDir?: string | null;
@@ -104,6 +105,7 @@ function mockTextRead(overrides: TextReadDataOverrides = {}) {
104
105
  function renderWorkspaceFilePreview({
105
106
  file,
106
107
  onFileOpen = vi.fn(),
108
+ onHtmlContentHeightChange,
107
109
  refreshVersion,
108
110
  sessionProjectRoot = "/tmp",
109
111
  sessionWorkingDir = "/tmp",
@@ -116,6 +118,7 @@ function renderWorkspaceFilePreview({
116
118
  sessionWorkingDir={sessionWorkingDir}
117
119
  refreshVersion={refreshVersion}
118
120
  showBreadcrumbs={showBreadcrumbs}
121
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
119
122
  onFileOpen={onFileOpen}
120
123
  />,
121
124
  );
@@ -316,9 +319,7 @@ describe("ChatSessionWorkspaceFilePreview rendering", () => {
316
319
  });
317
320
 
318
321
  expect(screen.getByTestId(testId)).toBeTruthy();
319
- expect(
320
- screen.queryByText(t("chatWorkspacePreviewTruncated")),
321
- ).toBeNull();
322
+ expect(screen.queryByText(t("chatWorkspacePreviewTruncated"))).toBeNull();
322
323
  },
323
324
  );
324
325
  });
@@ -751,9 +752,7 @@ describe("ChatSessionWorkspaceFilePreview text rendering", () => {
751
752
  });
752
753
  const indexButton = screen.getByRole("button", { name: "index.ts" });
753
754
  fireEvent.click(componentsTreeItem.querySelector("button")!);
754
- expect(
755
- componentsTreeItem.getAttribute("aria-expanded"),
756
- ).toBe("true");
755
+ expect(componentsTreeItem.getAttribute("aria-expanded")).toBe("true");
757
756
  expect(onFileOpen).not.toHaveBeenCalled();
758
757
 
759
758
  fireEvent.click(indexButton);
@@ -0,0 +1,67 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { WorkspaceFileContentPreview } from "@/features/chat/features/workspace/components/workspace-file-content-preview";
4
+
5
+ const defaultResizeObserver = globalThis.ResizeObserver;
6
+
7
+ afterEach(() => {
8
+ vi.stubGlobal("ResizeObserver", defaultResizeObserver);
9
+ });
10
+
11
+ it("reports same-origin HTML height changes without replacing the iframe", () => {
12
+ const observe = vi.fn();
13
+ const disconnect = vi.fn();
14
+ let notifyResize: ResizeObserverCallback = () => undefined;
15
+ class HeightResizeObserver {
16
+ constructor(callback: ResizeObserverCallback) {
17
+ notifyResize = callback;
18
+ }
19
+
20
+ observe = observe;
21
+
22
+ unobserve = vi.fn();
23
+
24
+ disconnect = disconnect;
25
+ }
26
+ vi.stubGlobal("ResizeObserver", HeightResizeObserver);
27
+ const onHtmlContentHeightChange = vi.fn();
28
+ const { unmount } = render(
29
+ <WorkspaceFileContentPreview
30
+ contentUrl="/api/server-paths/content?path=%2Ftmp%2Fexample.html"
31
+ kind="html"
32
+ label="example.html"
33
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
34
+ />,
35
+ );
36
+ const frame = screen.getByTestId(
37
+ "workspace-html-preview",
38
+ ) as HTMLIFrameElement;
39
+ const iframeDocument = document.implementation.createHTMLDocument();
40
+ Object.defineProperty(frame, "contentDocument", {
41
+ configurable: true,
42
+ value: iframeDocument,
43
+ });
44
+ const { documentElement } = iframeDocument;
45
+ Object.defineProperty(documentElement, "scrollHeight", {
46
+ configurable: true,
47
+ value: 640,
48
+ });
49
+
50
+ fireEvent.load(frame);
51
+
52
+ expect(onHtmlContentHeightChange).toHaveBeenLastCalledWith(640);
53
+ expect(observe).toHaveBeenCalledWith(documentElement);
54
+ expect(screen.getByTestId("workspace-html-preview")).toBe(frame);
55
+
56
+ Object.defineProperty(documentElement, "scrollHeight", {
57
+ configurable: true,
58
+ value: 900,
59
+ });
60
+ notifyResize([], {} as ResizeObserver);
61
+
62
+ expect(onHtmlContentHeightChange).toHaveBeenLastCalledWith(900);
63
+ expect(screen.getByTestId("workspace-html-preview")).toBe(frame);
64
+
65
+ unmount();
66
+ expect(disconnect).toHaveBeenCalledTimes(1);
67
+ });
@@ -180,6 +180,7 @@ function WorkspacePreviewBody({
180
180
  directoryQuery,
181
181
  fileBasePath,
182
182
  onFileOpen,
183
+ onHtmlContentHeightChange,
183
184
  previewBlock,
184
185
  previewKind,
185
186
  previewViewer,
@@ -194,6 +195,7 @@ function WorkspacePreviewBody({
194
195
  directoryQuery: ReturnType<typeof useServerPathBrowse> | null | undefined;
195
196
  fileBasePath: string | null;
196
197
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
198
+ onHtmlContentHeightChange?: (height: number) => void;
197
199
  previewBlock: ChatFileOperationBlockViewModel | null;
198
200
  previewKind: "text" | "markdown" | "binary";
199
201
  previewViewer: "source" | "rendered" | null;
@@ -208,6 +210,7 @@ function WorkspacePreviewBody({
208
210
  contentUrl={contentUrl}
209
211
  kind={contentUrlKind}
210
212
  label={contentLabel}
213
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
211
214
  />
212
215
  );
213
216
  }
@@ -291,6 +294,7 @@ type ChatSessionWorkspaceFilePreviewProps = {
291
294
  sessionProjectRoot: string | null;
292
295
  sessionWorkingDir: string | null;
293
296
  showBreadcrumbs?: boolean;
297
+ onHtmlContentHeightChange?: (height: number) => void;
294
298
  onFileOpen: (action: ChatFileOpenActionViewModel) => void;
295
299
  };
296
300
 
@@ -300,6 +304,7 @@ export function ChatSessionWorkspaceFilePreview({
300
304
  sessionProjectRoot,
301
305
  sessionWorkingDir,
302
306
  showBreadcrumbs = true,
307
+ onHtmlContentHeightChange,
303
308
  onFileOpen,
304
309
  }: ChatSessionWorkspaceFilePreviewProps) {
305
310
  const isPreviewMode = file.viewMode === "preview";
@@ -384,7 +389,8 @@ export function ChatSessionWorkspaceFilePreview({
384
389
  previewQuery?.data?.startLine,
385
390
  previewText,
386
391
  ]);
387
- const isTextPreviewTruncated = !contentUrl && Boolean(previewQuery?.data?.truncated);
392
+ const isTextPreviewTruncated =
393
+ !contentUrl && Boolean(previewQuery?.data?.truncated);
388
394
  const breadcrumbBasePath = sessionProjectRoot ?? sessionWorkingDir;
389
395
  const breadcrumb = useMemo(
390
396
  () =>
@@ -394,12 +400,7 @@ export function ChatSessionWorkspaceFilePreview({
394
400
  sessionProjectRoot: breadcrumbBasePath,
395
401
  truncated: isTextPreviewTruncated,
396
402
  }),
397
- [
398
- breadcrumbBasePath,
399
- isTextPreviewTruncated,
400
- previewPathKind,
401
- resolvedPath,
402
- ],
403
+ [breadcrumbBasePath, isTextPreviewTruncated, previewPathKind, resolvedPath],
403
404
  );
404
405
 
405
406
  return (
@@ -422,6 +423,7 @@ export function ChatSessionWorkspaceFilePreview({
422
423
  directoryQuery={directoryQuery}
423
424
  fileBasePath={sessionWorkingDir}
424
425
  onFileOpen={onFileOpen}
426
+ onHtmlContentHeightChange={onHtmlContentHeightChange}
425
427
  previewBlock={previewBlock}
426
428
  previewKind={previewKind}
427
429
  previewViewer={previewViewer}