authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-22 23:51:31+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 23:51:31+02:00
log593289c3c771ec1987b3f904eb95305d7ccaecf6
treec95ebb62b73784fb7ed9aa0f4efe499409c41024
parentab4a88386d5227706fadc4feb045dfb3e7fe6e1d
signature Signed by PGP key B5690EEEBB952194

Refine light-mode sidebar surfaces (#4268)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: codex <codex@users.noreply.github.com>

30 files changed, 1825 insertions(+), 878 deletions(-)

apps/mobile/src/features/home/HomeHeader.tsx+119-30
......@@ -4,6 +4,8 @@ import type {
44 SidebarThreadSortOrder,
55} from "@t3tools/contracts";
66import type { MenuAction } from "@react-native-menu/menu";
7import { useAtomValue } from "@effect/atom-react";
8import { AsyncResult } from "effect/unstable/reactivity";
79import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
810import { useCallback, useMemo, useRef } from "react";
911import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
......@@ -14,6 +16,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
1416import { SymbolView } from "../../components/AppSymbol";
1517import { T3Wordmark } from "../../components/T3Wordmark";
1618import { useThemeColor } from "../../lib/useThemeColor";
19import { mobilePreferencesAtom } from "../../state/preferences";
1720import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
1821import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
1922import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
......@@ -21,6 +24,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList";
2124import {
2225 buildHomeListFilterMenu,
2326 type HomeListFilterMenuEnvironment,
27 type HomeListFilterMenuProject,
2428} from "./home-list-filter-menu";
2529import {
2630 hasCustomHomeListOptions,
......@@ -33,13 +37,16 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
3337
3438export function HomeHeader(props: {
3539 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
40 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
3641 readonly searchQuery: string;
3742 readonly selectedEnvironmentId: EnvironmentId | null;
43 readonly selectedProjectKey: string | null;
3844 readonly projectSortOrder: HomeProjectSortOrder;
3945 readonly threadSortOrder: SidebarThreadSortOrder;
4046 readonly projectGroupingMode: SidebarProjectGroupingMode;
4147 readonly onSearchQueryChange: (query: string) => void;
4248 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
49 readonly onProjectChange: (projectKey: string | null) => void;
4350 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
4451 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
4552 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
......@@ -59,11 +66,24 @@ function checkedMenuState(checked: boolean) {
5966 return checked ? ("on" as const) : undefined;
6067}
6168
69/** Thread List v2 lays the list out in fixed creation order, so the
70 sort/group filter controls would be silently ignored — hide them and
71 key the "customized" icon state off the environment filter alone. */
72function useThreadListV2FilterGate() {
73 const preferencesResult = useAtomValue(mobilePreferencesAtom);
74 return (
75 AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true
76 );
77}
78
6279function AndroidHomeHeader(props: HomeHeaderProps) {
6380 const insets = useSafeAreaInsets();
6481 const iconColor = useThemeColor("--color-icon");
6582 const mutedColor = useThemeColor("--color-foreground-muted");
66 const hasCustomListOptions = hasCustomHomeListOptions(props);
83 const threadListV2Enabled = useThreadListV2FilterGate();
84 const hasCustomListOptions = threadListV2Enabled
85 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
86 : hasCustomHomeListOptions(props);
6787 const menuActions = useMemo<MenuAction[]>(
6888 () => [
6989 {
......@@ -82,40 +102,67 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
82102 })),
83103 ],
84104 },
85 {
86 id: "project-sort",
87 title: "Sort projects",
88 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
89 id: `project-sort:${option.value}`,
90 title: option.label,
91 state: checkedMenuState(props.projectSortOrder === option.value),
92 })),
93 },
94 {
95 id: "thread-sort",
96 title: "Sort threads",
97 subactions: THREAD_SORT_OPTIONS.map((option) => ({
98 id: `thread-sort:${option.value}`,
99 title: option.label,
100 state: checkedMenuState(props.threadSortOrder === option.value),
101 })),
102 },
103 {
104 id: "project-grouping",
105 title: "Group projects",
106 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
107 id: `project-grouping:${option.value}`,
108 title: option.label,
109 state: checkedMenuState(props.projectGroupingMode === option.value),
110 })),
111 },
105 ...(props.projects.length === 0
106 ? []
107 : ([
108 {
109 id: "project",
110 title: "Project",
111 subactions: [
112 {
113 id: "project:all",
114 title: "All projects",
115 state: checkedMenuState(props.selectedProjectKey === null),
116 },
117 ...props.projects.map((project) => ({
118 id: `project:${project.key}`,
119 title: project.label,
120 state: checkedMenuState(props.selectedProjectKey === project.key),
121 })),
122 ],
123 },
124 ] satisfies MenuAction[])),
125 ...(threadListV2Enabled
126 ? []
127 : ([
128 {
129 id: "project-sort",
130 title: "Sort projects",
131 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
132 id: `project-sort:${option.value}`,
133 title: option.label,
134 state: checkedMenuState(props.projectSortOrder === option.value),
135 })),
136 },
137 {
138 id: "thread-sort",
139 title: "Sort threads",
140 subactions: THREAD_SORT_OPTIONS.map((option) => ({
141 id: `thread-sort:${option.value}`,
142 title: option.label,
143 state: checkedMenuState(props.threadSortOrder === option.value),
144 })),
145 },
146 {
147 id: "project-grouping",
148 title: "Group projects",
149 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
150 id: `project-grouping:${option.value}`,
151 title: option.label,
152 state: checkedMenuState(props.projectGroupingMode === option.value),
153 })),
154 },
155 ] satisfies MenuAction[])),
112156 ],
113157 [
114158 props.environments,
115159 props.projectGroupingMode,
116160 props.projectSortOrder,
161 props.projects,
117162 props.selectedEnvironmentId,
163 props.selectedProjectKey,
118164 props.threadSortOrder,
165 threadListV2Enabled,
119166 ],
120167 );
121168 const handleMenuAction = useCallback(
......@@ -137,6 +184,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
137184 return;
138185 }
139186
187 if (id === "project:all") {
188 props.onProjectChange(null);
189 return;
190 }
191
192 if (id.startsWith("project:")) {
193 const projectKey = id.slice("project:".length);
194 if (props.projects.some((project) => project.key === projectKey)) {
195 props.onProjectChange(projectKey);
196 }
197 return;
198 }
199
140200 const projectSort = PROJECT_SORT_OPTIONS.find(
141201 (option) => id === `project-sort:${option.value}`,
142202 );
......@@ -255,17 +315,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
255315function IosHomeHeader(props: HomeHeaderProps) {
256316 const searchBarRef = useRef<SearchBarCommands>(null);
257317 const iconColor = useThemeColor("--color-icon");
258 const hasCustomListOptions = hasCustomHomeListOptions(props);
318 const threadListV2Enabled = useThreadListV2FilterGate();
319 const hasCustomListOptions = threadListV2Enabled
320 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
321 : hasCustomHomeListOptions(props);
259322 const focusSearch = useCallback(() => {
260323 searchBarRef.current?.focus();
261324 return searchBarRef.current !== null;
262325 }, []);
263326 useHardwareKeyboardCommand("focusSearch", focusSearch);
264 const filterMenu = buildHomeListFilterMenu(props);
327 const filterMenu = buildHomeListFilterMenu({
328 ...props,
329 listOrganization: !threadListV2Enabled,
330 });
265331
266332 return (
267333 <>
268334 <NativeStackScreenOptions
335 optionsVersion={filterMenu.items}
269336 options={{
270337 // Static header config (glass, title, fonts) lives in Stack.tsx
271338 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
......@@ -366,6 +433,28 @@ function IosHomeHeader(props: HomeHeaderProps) {
366433 ))}
367434 </NativeHeaderToolbar.Menu>
368435
436 {props.projects.length > 0 ? (
437 <NativeHeaderToolbar.Menu title="Project">
438 <NativeHeaderToolbar.Label>Project</NativeHeaderToolbar.Label>
439 <NativeHeaderToolbar.MenuAction
440 isOn={props.selectedProjectKey === null}
441 onPress={() => props.onProjectChange(null)}
442 subtitle="Show threads from every project"
443 >
444 <NativeHeaderToolbar.Label>All projects</NativeHeaderToolbar.Label>
445 </NativeHeaderToolbar.MenuAction>
446 {props.projects.map((project) => (
447 <NativeHeaderToolbar.MenuAction
448 key={project.key}
449 isOn={props.selectedProjectKey === project.key}
450 onPress={() => props.onProjectChange(project.key)}
451 >
452 <NativeHeaderToolbar.Label>{project.label}</NativeHeaderToolbar.Label>
453 </NativeHeaderToolbar.MenuAction>
454 ))}
455 </NativeHeaderToolbar.Menu>
456 ) : null}
457
369458 <NativeHeaderToolbar.Menu title="Sort projects">
370459 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
371460 {PROJECT_SORT_OPTIONS.map((option) => (
apps/mobile/src/features/home/HomeRouteScreen.tsx+29-1
......@@ -1,9 +1,10 @@
11import * as Arr from "effect/Array";
22import * as Order from "effect/Order";
33import { useNavigation } from "@react-navigation/native";
4import { useMemo, useState } from "react";
4import { useEffect, useMemo, useState } from "react";
55
66import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { scopedProjectKey } from "../../lib/scopedEntities";
78import { useProjects, useThreadShells } from "../../state/entities";
89import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
910import { useWorkspaceState } from "../../state/workspace";
......@@ -58,6 +59,28 @@ export function HomeRouteScreen() {
5859 setThreadSortOrder,
5960 } = useHomeListOptions(availableEnvironmentIds);
6061 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
62 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
63 const projectFilterOptions = useMemo(
64 () =>
65 projects
66 .filter(
67 (project) =>
68 selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId,
69 )
70 .map((project) => ({
71 key: scopedProjectKey(project.environmentId, project.id),
72 label: project.title,
73 })),
74 [projects, selectedEnvironmentId],
75 );
76 useEffect(() => {
77 if (
78 selectedProjectKey !== null &&
79 !projectFilterOptions.some((project) => project.key === selectedProjectKey)
80 ) {
81 setSelectedProjectKey(null);
82 }
83 }, [projectFilterOptions, selectedProjectKey]);
6184
6285 // In split layouts the persistent sidebar IS the thread list — Home becomes
6386 // an empty detail pane so selecting a thread never transitions layouts.
......@@ -90,12 +113,15 @@ export function HomeRouteScreen() {
90113 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
91114 <HomeHeader
92115 environments={environments}
116 projects={projectFilterOptions}
93117 searchQuery={searchQuery}
94118 selectedEnvironmentId={selectedEnvironmentId}
119 selectedProjectKey={selectedProjectKey}
95120 projectSortOrder={listOptions.projectSortOrder}
96121 threadSortOrder={listOptions.threadSortOrder}
97122 projectGroupingMode={listOptions.projectGroupingMode}
98123 onEnvironmentChange={setSelectedEnvironmentId}
124 onProjectChange={setSelectedProjectKey}
99125 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
100126 onProjectGroupingModeChange={setProjectGroupingMode}
101127 onProjectSortOrderChange={setProjectSortOrder}
......@@ -115,6 +141,7 @@ export function HomeRouteScreen() {
115141 onSettleThread={settleThread}
116142 onUnsettleThread={unsettleThread}
117143 onEnvironmentChange={setSelectedEnvironmentId}
144 onProjectChange={setSelectedProjectKey}
118145 onOpenEnvironments={() =>
119146 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
120147 }
......@@ -151,6 +178,7 @@ export function HomeRouteScreen() {
151178 savedConnectionsById={savedConnectionsById}
152179 searchQuery={searchQuery}
153180 selectedEnvironmentId={selectedEnvironmentId}
181 selectedProjectKey={selectedProjectKey}
154182 threads={threads}
155183 threadSortOrder={listOptions.threadSortOrder}
156184 />
apps/mobile/src/features/home/HomeScreen.tsx+77-98
......@@ -38,7 +38,12 @@ import {
3838 ThreadListShowMoreRow,
3939} from "../threads/thread-list-items";
4040import { ThreadListV2Row } from "../threads/thread-list-v2-items";
41import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2";
41import {
42 buildThreadListV2Items,
43 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
44 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
45 type ThreadListV2Item,
46} from "../threads/threadListV2";
4247import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
4348import {
4449 buildHomeListLayout,
......@@ -65,11 +70,13 @@ interface HomeScreenProps {
6570 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
6671 readonly searchQuery: string;
6772 readonly selectedEnvironmentId: EnvironmentId | null;
73 readonly selectedProjectKey: string | null;
6874 readonly projectSortOrder: HomeProjectSortOrder;
6975 readonly threadSortOrder: SidebarThreadSortOrder;
7076 readonly projectGroupingMode: SidebarProjectGroupingMode;
7177 readonly onSearchQueryChange: (query: string) => void;
7278 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
79 readonly onProjectChange: (projectKey: string | null) => void;
7380 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
7481 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
7582 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
......@@ -91,10 +98,6 @@ interface HomeScreenProps {
9198/* ─── Layout constants ───────────────────────────────────────────────── */
9299
93100const ESTIMATED_THREAD_ROW_HEIGHT = 72;
94// v2 settled-tail paging: recent history is the common lookup; the deep
95// tail stays behind an explicit Show more.
96const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
97const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
98101/**
99102 * Top spacing between the list and the Android custom header. The Android
100103 * header (AndroidHomeHeader) is rendered in-flow above this screen and
......@@ -169,80 +172,6 @@ function HomeTopContentSpacer() {
169172 return <View className="h-4" />;
170173}
171174
172function ThreadListV2ProjectScope(props: {
173 readonly projects: ReadonlyArray<EnvironmentProject>;
174 readonly selectedKey: string | null;
175 readonly onChange: (key: string | null) => void;
176}) {
177 if (props.projects.length === 0) return null;
178
179 return (
180 <ScrollView
181 horizontal
182 contentContainerStyle={{
183 gap: 8,
184 paddingHorizontal: 16,
185 paddingBottom: 8,
186 paddingTop: Platform.OS === "ios" ? 12 : 4,
187 }}
188 keyboardShouldPersistTaps="handled"
189 showsHorizontalScrollIndicator={false}
190 >
191 {props.projects.length > 1 ? (
192 <Pressable
193 accessibilityLabel="Show all threads"
194 accessibilityRole="button"
195 accessibilityState={{ selected: props.selectedKey === null }}
196 hitSlop={4}
197 onPress={() => props.onChange(null)}
198 className={cn(
199 "min-h-8 items-center justify-center rounded-lg border px-3",
200 props.selectedKey === null
201 ? "border-border bg-subtle-strong"
202 : "border-black/15 dark:border-white/15",
203 )}
204 >
205 <Text className="text-sm font-t3-medium text-foreground">All</Text>
206 </Pressable>
207 ) : null}
208 {props.projects.map((project) => {
209 const key = scopedProjectKey(project.environmentId, project.id);
210 const selected = props.selectedKey === key;
211 return (
212 <Pressable
213 key={key}
214 accessibilityLabel={`Show ${project.title} threads`}
215 accessibilityRole="button"
216 accessibilityState={{ selected }}
217 hitSlop={4}
218 onPress={() => props.onChange(selected ? null : key)}
219 className={cn(
220 "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3",
221 selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15",
222 )}
223 >
224 <ProjectFavicon
225 environmentId={project.environmentId}
226 projectTitle={project.title}
227 size={15}
228 workspaceRoot={project.workspaceRoot}
229 />
230 <Text
231 className={cn(
232 "max-w-36 text-sm font-t3-medium",
233 selected ? "text-foreground" : "text-foreground-muted",
234 )}
235 numberOfLines={1}
236 >
237 {project.title}
238 </Text>
239 </Pressable>
240 );
241 })}
242 </ScrollView>
243 );
244}
245
246175/* ─── Main screen ────────────────────────────────────────────────────── */
247176
248177export function HomeScreen(props: HomeScreenProps) {
......@@ -314,12 +243,51 @@ export function HomeScreen(props: HomeScreenProps) {
314243 onScrollBeginDrag: handleScrollBeginDrag,
315244 });
316245
246 const scopedProject = useMemo(
247 () =>
248 props.selectedProjectKey === null
249 ? null
250 : (props.projects.find(
251 (project) =>
252 scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey &&
253 (props.selectedEnvironmentId === null ||
254 project.environmentId === props.selectedEnvironmentId),
255 ) ?? null),
256 [props.projects, props.selectedEnvironmentId, props.selectedProjectKey],
257 );
258 const scopedProjects = useMemo(
259 () => (scopedProject === null ? props.projects : [scopedProject]),
260 [props.projects, scopedProject],
261 );
262 const scopedThreads = useMemo(
263 () =>
264 scopedProject === null
265 ? props.threads
266 : props.threads.filter(
267 (thread) =>
268 thread.environmentId === scopedProject.environmentId &&
269 thread.projectId === scopedProject.id,
270 ),
271 [props.threads, scopedProject],
272 );
273 const scopedPendingTasks = useMemo(
274 () =>
275 scopedProject === null
276 ? props.pendingTasks
277 : props.pendingTasks.filter(
278 (pendingTask) =>
279 pendingTask.message.environmentId === scopedProject.environmentId &&
280 pendingTask.creation.projectId === scopedProject.id,
281 ),
282 [props.pendingTasks, scopedProject],
283 );
284
317285 const projectGroups = useMemo(
318286 () =>
319287 buildHomeThreadGroups({
320 projects: props.projects,
321 threads: props.threads,
322 pendingTasks: props.pendingTasks,
288 projects: scopedProjects,
289 threads: scopedThreads,
290 pendingTasks: scopedPendingTasks,
323291 environmentId: props.selectedEnvironmentId,
324292 searchQuery: props.searchQuery,
325293 projectSortOrder: props.projectSortOrder,
......@@ -327,14 +295,14 @@ export function HomeScreen(props: HomeScreenProps) {
327295 projectGroupingMode: props.projectGroupingMode,
328296 }),
329297 [
330 props.pendingTasks,
331298 props.projectGroupingMode,
332 props.projects,
333299 props.projectSortOrder,
334300 props.searchQuery,
335301 props.selectedEnvironmentId,
336302 props.threadSortOrder,
337 props.threads,
303 scopedPendingTasks,
304 scopedProjects,
305 scopedThreads,
338306 ],
339307 );
340308
......@@ -365,7 +333,8 @@ export function HomeScreen(props: HomeScreenProps) {
365333 return map;
366334 }, [props.projects]);
367335
368 const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState<string | null>(null);
336 const v2ProjectScopeKey = props.selectedProjectKey;
337 const setV2ProjectScopeKey = props.onProjectChange;
369338 const v2ScopeProjects = useMemo(
370339 () =>
371340 props.selectedEnvironmentId === null
......@@ -382,12 +351,6 @@ export function HomeScreen(props: HomeScreenProps) {
382351 ) ?? null),
383352 [v2ProjectScopeKey, v2ScopeProjects],
384353 );
385 useEffect(() => {
386 if (v2ProjectScopeKey !== null && v2ScopedProject === null) {
387 setV2ProjectScopeKey(null);
388 }
389 }, [v2ProjectScopeKey, v2ScopedProject]);
390
391354 // Thread List v2 (beta): one flat list in creation order, no grouping.
392355 // Settled threads collapse into a recency tail below the card block.
393356 // Settled threads stay in the live shell stream (settled ≠ archived), so
......@@ -442,6 +405,10 @@ export function HomeScreen(props: HomeScreenProps) {
442405 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
443406 useEffect(() => {
444407 if (!threadListV2Enabled) return;
408 // Refresh immediately on enable: the mount-time value can be hours old
409 // by the time the beta is switched on, which would misclassify the
410 // inactivity auto-settle boundary until the first tick.
411 setNowMinute(new Date().toISOString().slice(0, 16));
445412 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
446413 return () => clearInterval(id);
447414 }, [threadListV2Enabled]);
......@@ -509,6 +476,11 @@ export function HomeScreen(props: HomeScreenProps) {
509476 (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId),
510477 )?.driver ?? null
511478 }
479 environmentLabel={
480 Object.keys(props.savedConnectionsById).length > 1
481 ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null)
482 : null
483 }
512484 onSelectThread={props.onSelectThread}
513485 onDeleteThread={handleDeleteThread}
514486 onArchiveThread={props.onArchiveThread}
......@@ -535,6 +507,7 @@ export function HomeScreen(props: HomeScreenProps) {
535507 projectCwdByKey,
536508 props.onArchiveThread,
537509 props.onSelectThread,
510 props.savedConnectionsById,
538511 serverConfigs,
539512 settlementEnvironmentIds,
540513 ],
......@@ -731,14 +704,11 @@ export function HomeScreen(props: HomeScreenProps) {
731704 pendingTask.creation.projectId === v2ScopedProject.id)) &&
732705 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
733706 );
707 // Project scoping lives in the header filter menu (no inline chip row on
708 // mobile — the menu is the one filter surface).
734709 const v2ListHeader = (
735710 <>
736711 {listHeader}
737 <ThreadListV2ProjectScope
738 projects={v2ScopeProjects}
739 selectedKey={v2ProjectScopeKey}
740 onChange={setV2ProjectScopeKey}
741 />
742712 {v2PendingTasks.map((pendingTask, index) => (
743713 <PendingTaskListRow
744714 key={pendingTask.message.messageId}
......@@ -758,6 +728,11 @@ export function HomeScreen(props: HomeScreenProps) {
758728 const listEmpty = !hasResults ? (
759729 hasSearchQuery ? (
760730 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
731 ) : scopedProject !== null ? (
732 <EmptyState
733 title={`No threads in ${scopedProject.title}`}
734 detail="Choose another project or create a new task."
735 />
761736 ) : selectedEnvironmentLabel ? (
762737 <EmptyState
763738 title={`No threads in ${selectedEnvironmentLabel}`}
......@@ -792,7 +767,11 @@ export function HomeScreen(props: HomeScreenProps) {
792767 data={threadListV2Items}
793768 renderItem={renderV2Item}
794769 keyExtractor={v2KeyExtractor}
795 extraData={{ projectByKey, serverConfigs }}
770 extraData={{
771 projectByKey,
772 serverConfigs,
773 savedConnectionsById: props.savedConnectionsById,
774 }}
796775 ListHeaderComponent={v2ListHeader}
797776 ListFooterComponent={
798777 threadListV2Layout.hiddenSettledCount > 0 ? (
apps/mobile/src/features/home/home-list-filter-menu.test.ts created+44
......@@ -0,0 +1,44 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { buildHomeListFilterMenu } from "./home-list-filter-menu";
4
5describe("buildHomeListFilterMenu", () => {
6 it("adds a project scope submenu that selects and clears the same scope as the chips", () => {
7 const onProjectChange = vi.fn();
8 const menu = buildHomeListFilterMenu({
9 environments: [],
10 projects: [
11 { key: "environment-1:project-1", label: "Codething" },
12 { key: "environment-1:project-2", label: "Website" },
13 ],
14 selectedEnvironmentId: null,
15 selectedProjectKey: "environment-1:project-1",
16 projectSortOrder: "updated_at",
17 threadSortOrder: "updated_at",
18 projectGroupingMode: "repository",
19 onEnvironmentChange: vi.fn(),
20 onProjectChange,
21 onProjectSortOrderChange: vi.fn(),
22 onThreadSortOrderChange: vi.fn(),
23 onProjectGroupingModeChange: vi.fn(),
24 });
25
26 const projectMenu = menu.items.find(
27 (item) => item.type === "submenu" && item.title === "Project",
28 );
29 expect(projectMenu).toMatchObject({
30 type: "submenu",
31 items: [
32 { title: "All projects", state: "off" },
33 { title: "Codething", state: "on" },
34 { title: "Website", state: "off" },
35 ],
36 });
37 if (projectMenu?.type !== "submenu") throw new Error("Expected project submenu");
38
39 projectMenu.items[0]?.onPress();
40 projectMenu.items[2]?.onPress();
41 expect(onProjectChange).toHaveBeenNthCalledWith(1, null);
42 expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2");
43 });
44});
apps/mobile/src/features/home/home-list-filter-menu.ts+84-47
......@@ -16,6 +16,11 @@ export interface HomeListFilterMenuEnvironment {
1616 readonly label: string;
1717}
1818
19export interface HomeListFilterMenuProject {
20 readonly key: string;
21 readonly label: string;
22}
23
1924type HomeListFilterMenuAction = {
2025 readonly type: "action";
2126 readonly title: string;
......@@ -37,15 +42,22 @@ export interface HomeListFilterMenu {
3742
3843export function buildHomeListFilterMenu(props: {
3944 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
45 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
4046 readonly selectedEnvironmentId: EnvironmentId | null;
47 readonly selectedProjectKey: string | null;
4148 readonly projectSortOrder: HomeProjectSortOrder;
4249 readonly threadSortOrder: SidebarThreadSortOrder;
4350 readonly projectGroupingMode: SidebarProjectGroupingMode;
4451 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
52 readonly onProjectChange: (projectKey: string | null) => void;
4553 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
4654 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
4755 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
4856 readonly onOpenSettings?: () => void;
57 /** False hides the sort/group submenus. Thread List v2 uses a fixed
58 creation-order layout, so offering those controls while it silently
59 ignores them would be a lie; the environment filter still applies. */
60 readonly listOrganization?: boolean;
4961}): HomeListFilterMenu {
5062 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
5163
......@@ -57,61 +69,86 @@ export function buildHomeListFilterMenu(props: {
5769 });
5870 }
5971
60 items.push(
61 {
72 items.push({
73 type: "submenu",
74 title: "Environment",
75 items: [
76 {
77 type: "action",
78 title: "All environments",
79 subtitle: "Show threads from every environment",
80 state: props.selectedEnvironmentId === null ? "on" : "off",
81 onPress: () => props.onEnvironmentChange(null),
82 },
83 ...props.environments.map((environment) => ({
84 type: "action" as const,
85 title: environment.label,
86 state:
87 props.selectedEnvironmentId === environment.environmentId
88 ? ("on" as const)
89 : ("off" as const),
90 onPress: () => props.onEnvironmentChange(environment.environmentId),
91 })),
92 ],
93 });
94
95 if (props.projects.length > 0) {
96 items.push({
6297 type: "submenu",
63 title: "Environment",
98 title: "Project",
6499 items: [
65100 {
66101 type: "action",
67 title: "All environments",
68 subtitle: "Show threads from every environment",
69 state: props.selectedEnvironmentId === null ? "on" : "off",
70 onPress: () => props.onEnvironmentChange(null),
102 title: "All projects",
103 subtitle: "Show threads from every project",
104 state: props.selectedProjectKey === null ? "on" : "off",
105 onPress: () => props.onProjectChange(null),
71106 },
72 ...props.environments.map((environment) => ({
107 ...props.projects.map((project) => ({
73108 type: "action" as const,
74 title: environment.label,
75 state:
76 props.selectedEnvironmentId === environment.environmentId
77 ? ("on" as const)
78 : ("off" as const),
79 onPress: () => props.onEnvironmentChange(environment.environmentId),
109 title: project.label,
110 state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const),
111 onPress: () => props.onProjectChange(project.key),
80112 })),
81113 ],
82 },
83 {
84 type: "submenu",
85 title: "Sort projects",
86 items: PROJECT_SORT_OPTIONS.map((option) => ({
87 type: "action",
88 title: option.label,
89 state: props.projectSortOrder === option.value ? "on" : "off",
90 onPress: () => props.onProjectSortOrderChange(option.value),
91 })),
92 },
93 {
94 type: "submenu",
95 title: "Sort threads",
96 items: THREAD_SORT_OPTIONS.map((option) => ({
97 type: "action",
98 title: option.label,
99 state: props.threadSortOrder === option.value ? "on" : "off",
100 onPress: () => props.onThreadSortOrderChange(option.value),
101 })),
102 },
103 {
104 type: "submenu",
105 title: "Group projects",
106 items: PROJECT_GROUPING_OPTIONS.map((option) => ({
107 type: "action",
108 title: option.label,
109 subtitle: option.subtitle,
110 state: props.projectGroupingMode === option.value ? "on" : "off",
111 onPress: () => props.onProjectGroupingModeChange(option.value),
112 })),
113 },
114 );
114 });
115 }
116
117 if (props.listOrganization !== false) {
118 items.push(
119 {
120 type: "submenu",
121 title: "Sort projects",
122 items: PROJECT_SORT_OPTIONS.map((option) => ({
123 type: "action",
124 title: option.label,
125 state: props.projectSortOrder === option.value ? "on" : "off",
126 onPress: () => props.onProjectSortOrderChange(option.value),
127 })),
128 },
129 {
130 type: "submenu",
131 title: "Sort threads",
132 items: THREAD_SORT_OPTIONS.map((option) => ({
133 type: "action",
134 title: option.label,
135 state: props.threadSortOrder === option.value ? "on" : "off",
136 onPress: () => props.onThreadSortOrderChange(option.value),
137 })),
138 },
139 {
140 type: "submenu",
141 title: "Group projects",
142 items: PROJECT_GROUPING_OPTIONS.map((option) => ({
143 type: "action",
144 title: option.label,
145 subtitle: option.subtitle,
146 state: props.projectGroupingMode === option.value ? "on" : "off",
147 onPress: () => props.onProjectGroupingModeChange(option.value),
148 })),
149 },
150 );
151 }
115152
116153 return {
117154 title: "Thread list options",
apps/mobile/src/features/home/home-list-options.test.ts+3
......@@ -27,5 +27,8 @@ describe("home list options", () => {
2727 hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),
2828 ).toBe(true);
2929 expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true);
30 expect(
31 hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }),
32 ).toBe(true);
3033 });
3134});
apps/mobile/src/features/home/home-list-options.ts+4-1
......@@ -93,13 +93,16 @@ export function HomeListOptionsProvider({ children }: PropsWithChildren) {
9393 return createElement(HomeListOptionsContext, { value }, children);
9494}
9595
96export function hasCustomHomeListOptions(options: HomeListOptions): boolean {
96export function hasCustomHomeListOptions(
97 options: HomeListOptions & { readonly selectedProjectKey?: string | null },
98): boolean {
9799 const defaultProjectSortOrder =
98100 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
99101 ? "updated_at"
100102 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;
101103 return (
102104 options.selectedEnvironmentId !== null ||
105 (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) ||
103106 options.projectSortOrder !== defaultProjectSortOrder ||
104107 options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
105108 options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE
apps/mobile/src/features/home/thread-swipe-actions.tsx+24-12
......@@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText";
3636const ACTION_ITEM_WIDTH = 58;
3737const ACTION_CIRCLE_SIZE = 36;
3838const ACTION_ICON_SIZE = 15;
39const COMPACT_ACTION_CIRCLE_SIZE = 28;
40const COMPACT_ACTION_ICON_SIZE = 13;
3941
4042export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;
4143export const THREAD_SWIPE_SPRING = {
......@@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: {
163165export function ThreadSwipeable(props: {
164166 readonly backgroundColor: ColorValue;
165167 readonly children: (close: () => void) => ReactNode;
168 /** Uses action visuals that fit inside compact 44pt rows. The press target
169 * still spans the row's full height and width. */
170 readonly compactActions?: boolean;
166171 readonly containerStyle?: StyleProp<ViewStyle>;
167172 /** Disables NEW swipe activations (e.g. while the list scrolls). */
168173 readonly enabled?: boolean;
......@@ -258,6 +263,7 @@ export function ThreadSwipeable(props: {
258263 renderRightActions={(_progress, translation, methods) => (
259264 <ThreadSwipeActions
260265 backgroundColor={props.backgroundColor}
266 compact={props.compactActions === true}
261267 fullSwipeAction={props.fullSwipeAction ?? "delete"}
262268 fullSwipeThreshold={fullSwipeThreshold}
263269 onDelete={props.onDelete}
......@@ -285,6 +291,7 @@ export function ThreadSwipeable(props: {
285291function SwipeActionButton(props: {
286292 readonly accessibilityLabel: string;
287293 readonly backgroundColor: string;
294 readonly compact: boolean;
288295 readonly entryRange: readonly [number, number];
289296 readonly fullSwipeThreshold: number;
290297 readonly icon: ComponentProps<typeof SymbolView>["name"];
......@@ -293,6 +300,8 @@ function SwipeActionButton(props: {
293300 readonly stretchesOnFullSwipe: boolean;
294301 readonly translation: SharedValue<number>;
295302}) {
303 const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE;
304 const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE;
296305 const actionStyle = useAnimatedStyle(() => {
297306 const reveal = Math.max(-props.translation.value, 0);
298307 const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP);
......@@ -324,7 +333,7 @@ function SwipeActionButton(props: {
324333
325334 return {
326335 transform: [{ translateX: -stretch }],
327 width: ACTION_CIRCLE_SIZE + stretch,
336 width: circleSize + stretch,
328337 };
329338 });
330339 const iconStyle = useAnimatedStyle(() => {
......@@ -386,13 +395,13 @@ function SwipeActionButton(props: {
386395 width: "100%",
387396 })}
388397 >
389 <View style={{ height: ACTION_CIRCLE_SIZE, width: ACTION_CIRCLE_SIZE }}>
398 <View style={{ height: circleSize, width: circleSize }}>
390399 <Animated.View
391400 style={[
392401 {
393402 backgroundColor: props.backgroundColor,
394403 borderRadius: 999,
395 height: ACTION_CIRCLE_SIZE,
404 height: circleSize,
396405 left: 0,
397406 position: "absolute",
398407 top: 0,
......@@ -404,25 +413,25 @@ function SwipeActionButton(props: {
404413 style={[
405414 {
406415 alignItems: "center",
407 height: ACTION_CIRCLE_SIZE,
416 height: circleSize,
408417 justifyContent: "center",
409418 left: 0,
410419 position: "absolute",
411420 top: 0,
412 width: ACTION_CIRCLE_SIZE,
421 width: circleSize,
413422 },
414423 iconStyle,
415424 ]}
416425 >
417 <SymbolView
418 name={props.icon}
419 size={ACTION_ICON_SIZE}
420 tintColor="#ffffff"
421 type="monochrome"
422 />
426 <SymbolView name={props.icon} size={iconSize} tintColor="#ffffff" type="monochrome" />
423427 </Animated.View>
424428 </View>
425 <Animated.View style={[{ paddingTop: 2 }, labelStyle]}>
429 <Animated.View
430 style={[
431 { height: 14, justifyContent: "center", paddingTop: props.compact ? 0 : 2 },
432 labelStyle,
433 ]}
434 >
426435 <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}>
427436 {props.label}
428437 </Text>
......@@ -434,6 +443,7 @@ function SwipeActionButton(props: {
434443
435444export function ThreadSwipeActions(props: {
436445 readonly backgroundColor: ColorValue;
446 readonly compact: boolean;
437447 readonly fullSwipeAction?: "delete" | "primary";
438448 readonly fullSwipeThreshold: number;
439449 readonly onDelete: () => void;
......@@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: {
466476 <SwipeActionButton
467477 accessibilityLabel={props.primaryAction.accessibilityLabel}
468478 backgroundColor="#007aff"
479 compact={props.compact}
469480 entryRange={[ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]}
470481 fullSwipeThreshold={props.fullSwipeThreshold}
471482 icon={props.primaryAction.icon}
......@@ -477,6 +488,7 @@ export function ThreadSwipeActions(props: {
477488 <SwipeActionButton
478489 accessibilityLabel={`Delete ${props.threadTitle}`}
479490 backgroundColor="#ff2d55"
491 compact={props.compact}
480492 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
481493 fullSwipeThreshold={props.fullSwipeThreshold}
482494 icon="trash"
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+450-45
......@@ -5,9 +5,12 @@ import type {
55} from "@t3tools/client-runtime/state/shell";
66import { LegendList } from "@legendapp/list/react-native";
77import type { MenuAction } from "@react-native-menu/menu";
8import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
8import { useAtomValue } from "@effect/atom-react";
9import { AsyncResult } from "effect/unstable/reactivity";
10import type { EnvironmentId } from "@t3tools/contracts";
11import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
912import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
10import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";
13import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";
1114import { Gesture, GestureDetector } from "react-native-gesture-handler";
1215import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
1316import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -22,7 +25,9 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2225import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
2326import { useThemeColor } from "../../lib/useThemeColor";
2427import { useProjects, useThreadShells } from "../../state/entities";
25import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
28import { mobilePreferencesAtom } from "../../state/preferences";
29import { environmentServerConfigsAtom } from "../../state/server";
30import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";
2631import { useWorkspaceState } from "../../state/workspace";
2732import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
2833import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
......@@ -59,6 +64,27 @@ import {
5964 ThreadListRow,
6065 ThreadListShowMoreRow,
6166} from "./thread-list-items";
67import { ThreadListV2Row } from "./thread-list-v2-items";
68import {
69 buildThreadListV2Items,
70 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
71 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
72 type ThreadListV2Item,
73} from "./threadListV2";
74
75/** The sidebar list serves both lists: v1 grouped items or, when the Thread
76 List v2 beta is on, queued offline tasks, flat v2 rows, and a settled
77 "Show more" pager. */
78type SidebarListItem =
79 | HomeListItem
80 | {
81 readonly type: "v2-pending-task";
82 readonly key: string;
83 readonly pendingTask: PendingNewTask;
84 readonly isLast: boolean;
85 }
86 | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item }
87 | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number };
6288
6389/**
6490 * Shared capsule behind the sidebar header buttons — a native liquid-glass
......@@ -169,7 +195,12 @@ function ThreadNavigationSidebarPane(
169195 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
170196 const headerIsOverContentRef = useRef(false);
171197 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
172 const { archiveThread, confirmDeleteThread } = useThreadListActions();
198 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
199 useThreadListActions();
200 const preferencesResult = useAtomValue(mobilePreferencesAtom);
201 const threadListV2Enabled =
202 AsyncResult.isSuccess(preferencesResult) &&
203 preferencesResult.value.threadListV2Enabled === true;
173204 const pendingTasks = usePendingNewTasks();
174205 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
175206 const environments = useMemo(
......@@ -193,19 +224,77 @@ function ThreadNavigationSidebarPane(
193224 setProjectSortOrder,
194225 setThreadSortOrder,
195226 } = useHomeListOptions(availableEnvironmentIds);
227 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
228 const projectFilterOptions = useMemo(
229 () =>
230 projects
231 .filter(
232 (project) =>
233 options.selectedEnvironmentId === null ||
234 project.environmentId === options.selectedEnvironmentId,
235 )
236 .map((project) => ({
237 key: scopedProjectKey(project.environmentId, project.id),
238 label: project.title,
239 })),
240 [options.selectedEnvironmentId, projects],
241 );
242 const selectedProject = useMemo(
243 () =>
244 selectedProjectKey === null
245 ? null
246 : (projects.find(
247 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
248 ) ?? null),
249 [projects, selectedProjectKey],
250 );
251 useEffect(() => {
252 if (
253 selectedProjectKey !== null &&
254 !projectFilterOptions.some((project) => project.key === selectedProjectKey)
255 ) {
256 setSelectedProjectKey(null);
257 }
258 }, [projectFilterOptions, selectedProjectKey]);
259 const scopedProjects = useMemo(
260 () => (selectedProject === null ? projects : [selectedProject]),
261 [projects, selectedProject],
262 );
263 const scopedThreads = useMemo(
264 () =>
265 selectedProject === null
266 ? threads
267 : threads.filter(
268 (thread) =>
269 thread.environmentId === selectedProject.environmentId &&
270 thread.projectId === selectedProject.id,
271 ),
272 [selectedProject, threads],
273 );
274 const scopedPendingTasks = useMemo(
275 () =>
276 selectedProject === null
277 ? pendingTasks
278 : pendingTasks.filter(
279 (pendingTask) =>
280 pendingTask.message.environmentId === selectedProject.environmentId &&
281 pendingTask.creation.projectId === selectedProject.id,
282 ),
283 [pendingTasks, selectedProject],
284 );
196285 const groups = useMemo(
197286 () =>
198287 buildHomeThreadGroups({
199 projects,
200 threads,
201 pendingTasks,
288 projects: scopedProjects,
289 threads: scopedThreads,
290 pendingTasks: scopedPendingTasks,
202291 environmentId: options.selectedEnvironmentId,
203292 searchQuery: props.searchQuery,
204293 projectSortOrder: options.projectSortOrder,
205294 threadSortOrder: options.threadSortOrder,
206295 projectGroupingMode: options.projectGroupingMode,
207296 }),
208 [options, pendingTasks, projects, props.searchQuery, threads],
297 [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads],
209298 );
210299 const [groupDisplayStates, setGroupDisplayStates] = useState<
211300 ReadonlyMap<string, HomeGroupDisplayState>
......@@ -237,6 +326,153 @@ function ThreadNavigationSidebarPane(
237326 }
238327 return map;
239328 }, [projects]);
329 const projectByKey = useMemo(() => {
330 const map = new Map<string, EnvironmentProject>();
331 for (const project of projects) {
332 map.set(scopedProjectKey(project.environmentId, project.id), project);
333 }
334 return map;
335 }, [projects]);
336
337 // Thread List v2 (beta) support — same model as the compact Home list
338 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.
339 // PR states stream in per-row; merged/closed PRs auto-settle their thread
340 // on the next partition.
341 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
342 ReadonlyMap<string, "open" | "closed" | "merged">
343 >(() => new Map());
344 const handleChangeRequestState = useCallback(
345 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
346 setChangeRequestStateByKey((current) => {
347 if ((current.get(threadKey) ?? null) === state) return current;
348 const next = new Map(current);
349 if (state === null) {
350 next.delete(threadKey);
351 } else {
352 next.set(threadKey, state);
353 }
354 return next;
355 });
356 },
357 [],
358 );
359 // The settled tail renders in pages; expansion resets when the filter
360 // context changes so environment/search flips never inherit a deep page.
361 const [settledVisibleCount, setSettledVisibleCount] = useState(
362 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
363 );
364 const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`;
365 const lastSettledResetKeyRef = useRef(settledResetKey);
366 if (lastSettledResetKeyRef.current !== settledResetKey) {
367 lastSettledResetKeyRef.current = settledResetKey;
368 setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
369 }
370 const showMoreSettled = useCallback(
371 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
372 [],
373 );
374 // now ticks per minute so the inactivity auto-settle boundary is actually
375 // crossed while the pane stays open; without a clock dependency the
376 // partition memoizes a frozen "now".
377 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
378 useEffect(() => {
379 if (!threadListV2Enabled) return;
380 // Refresh immediately on enable: the mount-time value can be hours old
381 // by the time the beta is switched on, which would misclassify the
382 // inactivity auto-settle boundary until the first tick.
383 setNowMinute(new Date().toISOString().slice(0, 16));
384 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
385 return () => clearInterval(id);
386 }, [threadListV2Enabled]);
387 // Threads on servers without the settlement capability never classify as
388 // settled (the user could neither un-settle nor pin them).
389 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
390 const settlementEnvironmentIds = useMemo(() => {
391 const supported = new Set<EnvironmentId>();
392 for (const [environmentId, config] of serverConfigs) {
393 if (config.environment.capabilities.threadSettlement === true) {
394 supported.add(environmentId);
395 }
396 }
397 return supported;
398 }, [serverConfigs]);
399 const threadListV2Layout = useMemo(() => {
400 if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
401 return buildThreadListV2Items({
402 threads: threads.filter((thread) => thread.archivedAt === null),
403 environmentId: options.selectedEnvironmentId,
404 projectRef:
405 selectedProject === null
406 ? null
407 : {
408 environmentId: selectedProject.environmentId,
409 projectId: selectedProject.id,
410 },
411 searchQuery: props.searchQuery,
412 changeRequestStateByKey,
413 settlementEnvironmentIds,
414 settledLimit: settledVisibleCount,
415 now: `${nowMinute}:00.000Z`,
416 });
417 }, [
418 changeRequestStateByKey,
419 nowMinute,
420 options.selectedEnvironmentId,
421 props.searchQuery,
422 settledVisibleCount,
423 settlementEnvironmentIds,
424 threadListV2Enabled,
425 threads,
426 selectedProject,
427 ]);
428 const listItems = useMemo<readonly SidebarListItem[]>(() => {
429 if (!threadListV2Enabled) return listLayout.items;
430 // Queued offline tasks render above the thread rows (mirrors the
431 // compact Home v2 list): they are not thread shells, so the v2 item
432 // builder never sees them, but they must stay visible and deletable
433 // while their environment is offline. Same environment scope and
434 // search filter as the list.
435 const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase();
436 const v2PendingTasks = pendingTasks.filter(
437 (pendingTask) =>
438 (options.selectedEnvironmentId === null ||
439 pendingTask.message.environmentId === options.selectedEnvironmentId) &&
440 (selectedProject === null ||
441 (pendingTask.message.environmentId === selectedProject.environmentId &&
442 pendingTask.creation.projectId === selectedProject.id)) &&
443 (v2SearchQuery.length === 0 ||
444 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
445 );
446 const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({
447 type: "v2-pending-task" as const,
448 key: `v2-pending:${pendingTask.message.messageId}`,
449 pendingTask,
450 isLast: index === v2PendingTasks.length - 1,
451 }));
452 for (const item of threadListV2Layout.items) {
453 items.push({
454 type: "v2-thread" as const,
455 key: scopedThreadKey(item.thread.environmentId, item.thread.id),
456 item,
457 });
458 }
459 if (threadListV2Layout.hiddenSettledCount > 0) {
460 items.push({
461 type: "v2-show-more",
462 key: "v2-show-more",
463 hiddenCount: threadListV2Layout.hiddenSettledCount,
464 });
465 }
466 return items;
467 }, [
468 listLayout.items,
469 options.selectedEnvironmentId,
470 pendingTasks,
471 props.searchQuery,
472 selectedProject,
473 threadListV2Enabled,
474 threadListV2Layout,
475 ]);
240476 const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
241477 const listMenuActions = useMemo<MenuAction[]>(
242478 () => [
......@@ -260,36 +496,64 @@ function ThreadNavigationSidebarPane(
260496 })),
261497 ],
262498 },
263 {
264 id: "project-sort",
265 title: "Sort projects",
266 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
267 id: `project-sort:${option.value}`,
268 title: option.label,
269 state: options.projectSortOrder === option.value ? "on" : "off",
270 })),
271 },
272 {
273 id: "thread-sort",
274 title: "Sort threads",
275 subactions: THREAD_SORT_OPTIONS.map((option) => ({
276 id: `thread-sort:${option.value}`,
277 title: option.label,
278 state: options.threadSortOrder === option.value ? "on" : "off",
279 })),
280 },
281 {
282 id: "project-grouping",
283 title: "Group projects",
284 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
285 id: `project-grouping:${option.value}`,
286 title: option.label,
287 subtitle: option.subtitle,
288 state: options.projectGroupingMode === option.value ? "on" : "off",
289 })),
290 },
499 ...(projectFilterOptions.length === 0
500 ? []
501 : ([
502 {
503 id: "project",
504 title: "Project",
505 subactions: [
506 {
507 id: "project:all",
508 title: "All projects",
509 subtitle: "Show threads from every project",
510 state: selectedProjectKey === null ? "on" : "off",
511 },
512 ...projectFilterOptions.map((project) => ({
513 id: `project:${project.key}`,
514 title: project.label,
515 state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const),
516 })),
517 ],
518 },
519 ] satisfies MenuAction[])),
520 // v2 lays the list out in fixed creation order — offering sort/group
521 // controls it silently ignores would be a lie. Environment still
522 // scopes the v2 partition, so it stays.
523 ...(threadListV2Enabled
524 ? []
525 : ([
526 {
527 id: "project-sort",
528 title: "Sort projects",
529 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
530 id: `project-sort:${option.value}`,
531 title: option.label,
532 state: options.projectSortOrder === option.value ? "on" : "off",
533 })),
534 },
535 {
536 id: "thread-sort",
537 title: "Sort threads",
538 subactions: THREAD_SORT_OPTIONS.map((option) => ({
539 id: `thread-sort:${option.value}`,
540 title: option.label,
541 state: options.threadSortOrder === option.value ? "on" : "off",
542 })),
543 },
544 {
545 id: "project-grouping",
546 title: "Group projects",
547 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({
548 id: `project-grouping:${option.value}`,
549 title: option.label,
550 subtitle: option.subtitle,
551 state: options.projectGroupingMode === option.value ? "on" : "off",
552 })),
553 },
554 ] satisfies MenuAction[])),
291555 ],
292 [environments, options],
556 [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled],
293557 );
294558 const handleListMenuAction = useCallback(
295559 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -305,6 +569,17 @@ function ThreadNavigationSidebarPane(
305569 if (environment) setSelectedEnvironmentId(environment.environmentId);
306570 return;
307571 }
572 if (event === "project:all") {
573 setSelectedProjectKey(null);
574 return;
575 }
576 if (event.startsWith("project:")) {
577 const projectKey = event.slice("project:".length);
578 if (projectFilterOptions.some((project) => project.key === projectKey)) {
579 setSelectedProjectKey(projectKey);
580 }
581 return;
582 }
308583 const projectSort = PROJECT_SORT_OPTIONS.find(
309584 (option) => `project-sort:${option.value}` === event,
310585 );
......@@ -326,6 +601,7 @@ function ThreadNavigationSidebarPane(
326601 },
327602 [
328603 environments,
604 projectFilterOptions,
329605 setProjectGroupingMode,
330606 setProjectSortOrder,
331607 setSelectedEnvironmentId,
......@@ -382,7 +658,44 @@ function ThreadNavigationSidebarPane(
382658 onScroll: handleScroll,
383659 onScrollBeginDrag: handleScrollBeginDrag,
384660 });
385 const listExtraData = props.selectedThreadKey ?? "";
661 const listExtraData = useMemo(
662 () => ({
663 selectedThreadKey: props.selectedThreadKey ?? "",
664 savedConnectionsById,
665 serverConfigs,
666 }),
667 [props.selectedThreadKey, savedConnectionsById, serverConfigs],
668 );
669 const sidebarItemsAreEqual = useCallback(
670 (previous: SidebarListItem, item: SidebarListItem): boolean => {
671 if (previous.type === "v2-thread" && item.type === "v2-thread") {
672 return (
673 previous.key === item.key &&
674 previous.item.thread === item.item.thread &&
675 previous.item.variant === item.item.variant &&
676 previous.item.showSettledDivider === item.item.showSettledDivider
677 );
678 }
679 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
680 return previous.hiddenCount === item.hiddenCount;
681 }
682 if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") {
683 return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast;
684 }
685 if (
686 previous.type === "v2-thread" ||
687 previous.type === "v2-show-more" ||
688 previous.type === "v2-pending-task" ||
689 item.type === "v2-thread" ||
690 item.type === "v2-show-more" ||
691 item.type === "v2-pending-task"
692 ) {
693 return false;
694 }
695 return homeListItemsAreEqual(previous, item);
696 },
697 [],
698 );
386699 const focusSearch = useCallback(() => {
387700 const focus = () => {
388701 if (props.nativeChrome) {
......@@ -401,8 +714,78 @@ function ThreadNavigationSidebarPane(
401714 }, [props.nativeChrome, props.onRequestVisibility, props.visible]);
402715 useHardwareKeyboardCommand("focusSearch", focusSearch);
403716 const renderListItem = useCallback(
404 ({ item }: { readonly item: HomeListItem }) => {
717 ({ item }: { readonly item: SidebarListItem }) => {
405718 switch (item.type) {
719 case "v2-pending-task":
720 return (
721 <PendingTaskListRow
722 variant="sidebar"
723 pendingTask={item.pendingTask}
724 environmentLabel={
725 savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ??
726 null
727 }
728 isLast={item.isLast}
729 onSelectPendingTask={openPendingTask}
730 onDeletePendingTask={confirmDeletePendingTask}
731 />
732 );
733 case "v2-thread": {
734 const thread = item.item.thread;
735 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
736 return (
737 <ThreadListV2Row
738 thread={thread}
739 variant={item.item.variant}
740 showSettledDivider={item.item.showSettledDivider}
741 project={projectByKey.get(scopeKey) ?? null}
742 providerDriver={
743 serverConfigs
744 .get(thread.environmentId)
745 ?.providers.find(
746 (provider) =>
747 provider.instanceId ===
748 (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
749 )?.driver ?? null
750 }
751 environmentLabel={
752 Object.keys(savedConnectionsById).length > 1
753 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
754 : null
755 }
756 pane="sidebar"
757 selected={
758 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
759 }
760 fullSwipeWidth={props.width - 20}
761 onSelectThread={handleSelectThread}
762 onDeleteThread={confirmDeleteThread}
763 onArchiveThread={archiveThread}
764 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
765 onSettleThread={settleThread}
766 onUnsettleThread={unsettleThread}
767 onChangeRequestState={handleChangeRequestState}
768 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
769 onSwipeableClose={handleSwipeableClose}
770 onSwipeableWillOpen={handleSwipeableWillOpen}
771 simultaneousSwipeGesture={sidebarScrollGesture}
772 />
773 );
774 }
775 case "v2-show-more":
776 return (
777 <Pressable
778 accessibilityRole="button"
779 accessibilityLabel={`Show ${Math.min(item.hiddenCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
780 onPress={showMoreSettled}
781 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
782 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
783 >
784 <Text className="text-xs font-t3-medium text-foreground-muted">
785 Show more ({item.hiddenCount} settled hidden)
786 </Text>
787 </Pressable>
788 );
406789 case "header":
407790 return (
408791 <ThreadListGroupHeader
......@@ -478,42 +861,61 @@ function ThreadNavigationSidebarPane(
478861 archiveThread,
479862 confirmDeletePendingTask,
480863 confirmDeleteThread,
864 handleChangeRequestState,
481865 handleSelectThread,
482866 handleSwipeableClose,
483867 handleSwipeableWillOpen,
484868 openPendingTask,
869 projectByKey,
485870 projectCwdByKey,
486871 props.onNewThreadInProject,
487872 props.selectedThreadKey,
488873 props.width,
489874 savedConnectionsById,
875 serverConfigs,
876 settleThread,
877 settlementEnvironmentIds,
878 showMoreSettled,
490879 sidebarScrollGesture,
880 unsettleThread,
491881 updateGroupDisplay,
492882 ],
493883 );
494 const filterIcon = hasCustomHomeListOptions(options)
884 // v2 ignores the sort/group options, so only the environment filter can
885 // light the "customized" state while the beta is on.
886 const filterCustomized = threadListV2Enabled
887 ? options.selectedEnvironmentId !== null || selectedProjectKey !== null
888 : hasCustomHomeListOptions({ ...options, selectedProjectKey });
889 const filterIcon = filterCustomized
495890 ? "line.3.horizontal.decrease.circle.fill"
496891 : "line.3.horizontal.decrease.circle";
497892 const filterMenu = useMemo(
498893 () =>
499894 buildHomeListFilterMenu({
500895 environments,
896 projects: projectFilterOptions,
501897 selectedEnvironmentId: options.selectedEnvironmentId,
898 selectedProjectKey,
502899 projectSortOrder: options.projectSortOrder,
503900 threadSortOrder: options.threadSortOrder,
504901 projectGroupingMode: options.projectGroupingMode,
505902 onEnvironmentChange: setSelectedEnvironmentId,
903 onProjectChange: setSelectedProjectKey,
506904 onProjectSortOrderChange: setProjectSortOrder,
507905 onThreadSortOrderChange: setThreadSortOrder,
508906 onProjectGroupingModeChange: setProjectGroupingMode,
907 listOrganization: !threadListV2Enabled,
509908 }),
510909 [
511910 environments,
512911 options,
912 projectFilterOptions,
913 selectedProjectKey,
513914 setProjectGroupingMode,
514915 setProjectSortOrder,
515916 setSelectedEnvironmentId,
516917 setThreadSortOrder,
918 threadListV2Enabled,
517919 ],
518920 );
519921 const nativeHeaderItems = useMemo(
......@@ -531,7 +933,9 @@ function ThreadNavigationSidebarPane(
531933 ? "Loading threads…"
532934 : props.searchQuery.trim().length > 0
533935 ? "No matching threads"
534 : "No threads yet"}
936 : selectedProject !== null
937 ? `No threads in ${selectedProject.title}`
938 : "No threads yet"}
535939 </Text>
536940 );
537941
......@@ -539,6 +943,7 @@ function ThreadNavigationSidebarPane(
539943 return (
540944 <>
541945 <NativeStackScreenOptions
946 optionsVersion={nativeHeaderItems}
542947 options={{
543948 headerSearchBarOptions: {
544949 ref: searchBarRef,
......@@ -564,12 +969,12 @@ function ThreadNavigationSidebarPane(
564969 <SwipeableScrollGateProvider enabled={swipeEnabled}>
565970 <GestureDetector gesture={sidebarScrollGesture}>
566971 <LegendList
567 data={listLayout.items}
972 data={listItems}
568973 drawDistance={500}
569974 estimatedItemSize={64}
570975 extraData={listExtraData}
571976 getItemType={(item) => item.type}
572 itemsAreEqual={homeListItemsAreEqual}
977 itemsAreEqual={sidebarItemsAreEqual}
573978 keyExtractor={(item) => item.key}
574979 renderItem={renderListItem}
575980 automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}
......@@ -625,12 +1030,12 @@ function ThreadNavigationSidebarPane(
6251030 <SwipeableScrollGateProvider enabled={swipeEnabled}>
6261031 <GestureDetector gesture={sidebarScrollGesture}>
6271032 <LegendList
628 data={listLayout.items}
1033 data={listItems}
6291034 drawDistance={500}
6301035 estimatedItemSize={64}
6311036 extraData={listExtraData}
6321037 getItemType={(item) => item.type}
633 itemsAreEqual={homeListItemsAreEqual}
1038 itemsAreEqual={sidebarItemsAreEqual}
6341039 keyExtractor={(item) => item.key}
6351040 renderItem={renderListItem}
6361041 contentContainerStyle={[
apps/mobile/src/features/threads/thread-list-v2-items.tsx+214-80
......@@ -19,8 +19,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
1919import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";
2020
2121/**
22 * Thread List v2 rows mirror the web sidebar's compact tonal cards and
23 * receded settled tail while retaining native swipe and long-press actions.
22 * Thread List v2 renders one flat native list: rich edge-to-edge rows for
23 * active work and a receded settled tail, all with native swipe and
24 * long-press actions. State reads through colored status labels and text
25 * hierarchy rather than card fills.
2426 */
2527
2628const MONO_FONT = Platform.select({
......@@ -29,12 +31,15 @@ const MONO_FONT = Platform.select({
2931 default: "monospace",
3032});
3133
34// Status hues follow the system-wide convention set by sidebar v1 and the
35// Live Activity/widgets (amber approval, indigo input, sky working) so a
36// thread reads the same color everywhere it surfaces.
3237const STATUS_LABEL_BY_STATUS: Partial<
3338 Record<ThreadListV2Status, { label: string; className: string }>
3439> = {
3540 approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" },
36 input: { label: "Input", className: "text-amber-700 dark:text-amber-300" },
37 working: { label: "Working", className: "text-blue-600 dark:text-blue-400" },
41 input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" },
42 working: { label: "Working", className: "text-sky-600 dark:text-sky-400" },
3843 failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
3944};
4045
......@@ -60,10 +65,20 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [
6065 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
6166];
6267
63export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() {
68/** Rounded-row radius shared with the v1 sidebar rows. */
69const SIDEBAR_V2_ROW_RADIUS = 12;
70
71export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: {
72 readonly pane?: "screen" | "sidebar";
73}) {
6474 const borderColor = useThemeColor("--color-border");
6575 return (
66 <View className="mb-1.5 mt-4 flex-row items-center gap-2.5 px-5">
76 <View
77 className={cn(
78 "mb-1.5 mt-4 flex-row items-center gap-2.5",
79 props.pane === "sidebar" ? "px-3" : "px-5",
80 )}
81 >
6782 <Text className="text-xs font-t3-medium text-foreground-tertiary">Settled</Text>
6883 <View className="h-px flex-1" style={{ backgroundColor: borderColor }} />
6984 </View>
......@@ -76,6 +91,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
7691 readonly showSettledDivider: boolean;
7792 readonly project: EnvironmentProject | null;
7893 readonly providerDriver: string | null;
94 /** Which machine hosts the thread. Null when only one environment is
95 connected — repeating the same label on every row is noise. Mirrors
96 the web sidebar's remote-environment cloud icon, but as text since
97 phones have no hover tooltips. */
98 readonly environmentLabel: string | null;
99 /** Hosting surface. "screen" (default) renders the compact Home idiom:
100 flat edge-to-edge rows on the screen background with inset hairlines.
101 "sidebar" renders the iPad split-view idiom: rounded rows blending
102 into the drawer surface, selection filled with the accent color —
103 matching the v1 sidebar rows. */
104 readonly pane?: "screen" | "sidebar";
105 /** Highlights the thread open in the detail pane (iPad split view). The
106 compact Home list never sets it — phones navigate away on select. */
107 readonly selected?: boolean;
108 /** Override for narrow panes (iPad sidebar); defaults to window width. */
109 readonly fullSwipeWidth?: number;
79110 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
80111 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
81112 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
......@@ -117,6 +148,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
117148 }, [onChangeRequestState, prState, threadKey]);
118149
119150 const screenColor = useThemeColor("--color-screen");
151 const drawerColor = useThemeColor("--color-drawer");
152 const pressedBackgroundColor = useThemeColor("--color-subtle");
153 const selectedBackgroundColor = useThemeColor("--color-user-bubble");
154 const sidebarPane = props.pane === "sidebar";
155 const selected = props.selected === true;
120156
121157 const status = resolveThreadListV2Status(thread);
122158 const statusLabel = STATUS_LABEL_BY_STATUS[status];
......@@ -174,99 +210,184 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
174210 thread.title,
175211 ]);
176212
213 // The sidebar pane fills selected rows with the accent color (matching the
214 // v1 sidebar), so every piece of row text needs a white-on-accent variant.
215 const cardContent = (
216 <>
217 <View className="flex-row items-center gap-1.5">
218 {props.project ? (
219 <ProjectFavicon
220 environmentId={thread.environmentId}
221 size={15}
222 projectTitle={props.project.title}
223 workspaceRoot={props.project.workspaceRoot}
224 />
225 ) : null}
226 <Text
227 className={cn(
228 "flex-1 text-sm font-t3-medium",
229 selected ? "text-user-bubble-foreground-muted" : "text-foreground-muted",
230 )}
231 numberOfLines={1}
232 >
233 {props.project?.title ?? ""}
234 </Text>
235 <Text
236 className={cn(
237 "text-xs tabular-nums",
238 selected ? "text-white" : (statusLabel?.className ?? "text-foreground-tertiary"),
239 )}
240 >
241 {statusLabel?.label ?? timeLabel}
242 </Text>
243 </View>
244 <Text
245 className={cn(
246 "mt-1 text-base font-t3-medium",
247 selected ? "text-user-bubble-foreground" : "text-foreground",
248 )}
249 numberOfLines={2}
250 >
251 {thread.title}
252 </Text>
253 <View className="mt-1 flex-row items-center gap-2">
254 {status === "failed" && thread.session?.lastError ? (
255 <Text
256 className={cn(
257 "flex-1 text-xs",
258 selected
259 ? "text-user-bubble-foreground-muted"
260 : "text-red-600/80 dark:text-red-400/80",
261 )}
262 numberOfLines={1}
263 >
264 {thread.session.lastError}
265 </Text>
266 ) : thread.branch || props.environmentLabel ? (
267 /* "branch · machine" share one truncating line. The machine sits
268 last so a tight fit cuts the repetitive label, not the branch —
269 and machine-only fills the row for non-git projects. */
270 <Text
271 className={cn(
272 "flex-1 text-xs",
273 selected ? "text-user-bubble-foreground-muted" : "text-foreground-muted",
274 )}
275 numberOfLines={1}
276 >
277 {thread.branch ? (
278 <Text
279 className={cn(
280 "text-xs",
281 selected ? "text-user-bubble-foreground-muted" : "text-foreground-muted",
282 )}
283 style={{ fontFamily: MONO_FONT }}
284 >
285 {thread.branch}
286 </Text>
287 ) : null}
288 {thread.branch && props.environmentLabel ? " · " : null}
289 {props.environmentLabel ? (
290 <Text
291 className={cn(
292 "text-xs",
293 selected ? "text-user-bubble-foreground-muted" : "text-foreground-tertiary",
294 )}
295 >
296 {props.environmentLabel}
297 </Text>
298 ) : null}
299 </Text>
300 ) : (
301 <View className="flex-1" />
302 )}
303 {pr ? (
304 <Text
305 accessibilityLabel={pr.accessibilityLabel}
306 className={cn("text-xs", selected ? "text-white" : pr.textClassName)}
307 style={{ fontFamily: MONO_FONT }}
308 >
309 #{pr.label}
310 </Text>
311 ) : null}
312 {props.providerDriver ? (
313 <View className="opacity-60">
314 <ProviderIcon provider={props.providerDriver} size={14} />
315 </View>
316 ) : null}
317 </View>
318 </>
319 );
320
177321 const rowContent = (close: () => void) =>
178322 variant === "card" ? (
179323 <Pressable
180324 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
181325 accessibilityLabel={thread.title}
182326 accessibilityRole="button"
327 accessibilityState={{ selected }}
183328 onPress={() => {
184329 close();
185330 onSelectThread(thread);
186331 }}
187 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
332 style={
333 sidebarPane
334 ? ({ pressed }) => ({
335 backgroundColor: selected
336 ? selectedBackgroundColor
337 : pressed
338 ? pressedBackgroundColor
339 : drawerColor,
340 borderRadius: SIDEBAR_V2_ROW_RADIUS,
341 paddingHorizontal: 12,
342 paddingVertical: 10,
343 })
344 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })
345 }
188346 >
189 <View className="bg-screen px-4 py-1">
190 <View
191 className={cn(
192 "overflow-hidden",
193 status === "ready"
194 ? "bg-black/[0.025] dark:bg-white/[0.025]"
195 : "bg-black/[0.04] dark:bg-white/[0.04]",
196 )}
197 style={{ borderRadius: 12, borderCurve: "continuous", minHeight: 84 }}
198 >
199 <View className="px-3 py-2.5">
200 <View className="flex-row items-center gap-1.5">
201 {props.project ? (
202 <ProjectFavicon
203 environmentId={thread.environmentId}
204 size={15}
205 projectTitle={props.project.title}
206 workspaceRoot={props.project.workspaceRoot}
207 />
208 ) : null}
209 <Text
210 className="flex-1 text-sm font-t3-medium text-foreground-muted"
211 numberOfLines={1}
212 >
213 {props.project?.title ?? ""}
214 </Text>
215 <Text
216 className={cn(
217 "text-xs tabular-nums",
218 statusLabel?.className ?? "text-foreground-tertiary",
219 )}
220 >
221 {statusLabel?.label ?? timeLabel}
222 </Text>
223 </View>
224 <Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={2}>
225 {thread.title}
226 </Text>
227 <View className="mt-1 flex-row items-center gap-2">
228 {status === "failed" && thread.session?.lastError ? (
229 <Text
230 className="flex-1 text-xs text-red-600/80 dark:text-red-400/80"
231 numberOfLines={1}
232 >
233 {thread.session.lastError}
234 </Text>
235 ) : thread.branch ? (
236 <Text
237 className="flex-1 text-xs text-foreground-muted"
238 numberOfLines={1}
239 style={{ fontFamily: MONO_FONT }}
240 >
241 {thread.branch}
242 </Text>
243 ) : (
244 <View className="flex-1" />
245 )}
246 {props.providerDriver ? (
247 <View className="opacity-60">
248 <ProviderIcon provider={props.providerDriver} size={14} />
249 </View>
250 ) : null}
251 </View>
252 </View>
347 {sidebarPane ? (
348 cardContent
349 ) : (
350 /* Flat native list rows: no tonal containers — colored status
351 labels and text hierarchy carry state, an inset hairline
352 separates rows. The opaque screen background stays so swipe
353 actions reveal behind the row. */
354 <View className="bg-screen">
355 <View className="px-5 py-2.5">{cardContent}</View>
356 <View className="ml-5 h-px bg-border-subtle" />
253357 </View>
254 </View>
358 )}
255359 </Pressable>
256360 ) : (
257361 <Pressable
258362 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
259363 accessibilityLabel={thread.title}
260364 accessibilityRole="button"
261 className="bg-screen"
365 accessibilityState={{ selected }}
366 className={sidebarPane ? undefined : "bg-screen"}
262367 onPress={() => {
263368 close();
264369 onSelectThread(thread);
265370 }}
266 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
371 style={
372 sidebarPane
373 ? ({ pressed }) => ({
374 backgroundColor: selected
375 ? selectedBackgroundColor
376 : pressed
377 ? pressedBackgroundColor
378 : drawerColor,
379 borderRadius: SIDEBAR_V2_ROW_RADIUS,
380 })
381 : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })
382 }
267383 >
268384 {/* Settled history recedes: dimmed favicon + muted title. */}
269 <View className="min-h-[44px] flex-row items-center gap-2.5 px-5 py-2">
385 <View
386 className={cn(
387 "min-h-[44px] flex-row items-center gap-2.5 py-2",
388 sidebarPane ? "px-3" : "px-5",
389 )}
390 >
270391 {props.project ? (
271392 <View className="opacity-40">
272393 <ProjectFavicon
......@@ -277,11 +398,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
277398 />
278399 </View>
279400 ) : null}
280 <Text className="flex-1 text-base text-foreground-muted" numberOfLines={1}>
401 <Text
402 className={cn(
403 "flex-1 text-base",
404 selected ? "text-user-bubble-foreground" : "text-foreground-muted",
405 )}
406 numberOfLines={1}
407 >
281408 {thread.title}
282409 </Text>
283410 <Text
284 className="text-sm tabular-nums text-foreground-tertiary"
411 className={cn(
412 "text-sm tabular-nums",
413 selected ? "text-user-bubble-foreground-muted" : "text-foreground-tertiary",
414 )}
285415 style={{ fontFamily: MONO_FONT }}
286416 >
287417 {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
......@@ -292,14 +422,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
292422
293423 return (
294424 <>
295 {props.showSettledDivider ? <ThreadListV2SettledDivider /> : null}
425 {props.showSettledDivider ? <ThreadListV2SettledDivider pane={props.pane} /> : null}
296426 <ThreadSwipeable
297 backgroundColor={screenColor}
427 backgroundColor={sidebarPane ? drawerColor : screenColor}
428 compactActions={variant === "slim"}
429 containerStyle={
430 sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : undefined
431 }
298432 enableTrackpadSwipe
299433 // Full swipe commits the advertised lifecycle action (Settle /
300434 // Un-settle), never the destructive delete.
301435 fullSwipeAction="primary"
302 fullSwipeWidth={windowWidth - 32}
436 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
303437 onDelete={handleDelete}
304438 onSwipeableClose={props.onSwipeableClose}
305439 onSwipeableWillOpen={props.onSwipeableWillOpen}
apps/mobile/src/features/threads/threadListV2.ts+6
......@@ -12,6 +12,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1212 */
1313export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
1414
15// Settled-tail paging: recent history is the common lookup; the deep tail
16// stays behind an explicit Show more. Shared by the compact Home list and
17// the iPad sidebar so both page identically.
18export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
19export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
20
1521export function resolveThreadListV2Status(
1622 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
1723): ThreadListV2Status {
apps/mobile/src/native/StackHeader.tsx+9-2
......@@ -142,6 +142,13 @@ function stabilizeOptionFunctions(
142142
143143export function NativeStackScreenOptions(props: {
144144 readonly options?: AppNativeStackNavigationOptions;
145 /**
146 * Causes dynamic native header factories to be reapplied when their closed-over
147 * menu content changes. Factory functions are intentionally stabilized, so
148 * their source alone cannot capture a menu that was initially empty while
149 * asynchronous data was loading.
150 */
151 readonly optionsVersion?: unknown;
145152 readonly listeners?: Record<string, (event: never) => void>;
146153 readonly name?: string;
147154}) {
......@@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: {
163170 if (!navigation || !stableOptions) {
164171 return;
165172 }
166 const signature = optionsSignature(stableOptions);
173 const signature = optionsSignature([stableOptions, props.optionsVersion]);
167174 // Avoid re-entering navigation state when semantically equal options are
168175 // reapplied every layout (common when callers pass unstable object literals).
169176 if (lastAppliedOptionsSignatureRef.current === signature) {
......@@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: {
171178 }
172179 lastAppliedOptionsSignatureRef.current = signature;
173180 navigation.setOptions(stableOptions);
174 }, [navigation, stableOptions]);
181 }, [navigation, props.optionsVersion, stableOptions]);
175182
176183 useEffect(() => {
177184 if (!navigation || !props.listeners) {
apps/web/src/components/AppSidebarLayout.tsx+4-5
......@@ -84,7 +84,7 @@ function SidebarControl() {
8484 "pointer-events-auto",
8585 isSidebarVisible &&
8686 stageBackdropVariant &&
87 "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!",
87 "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!",
8888 )}
8989 aria-label="Toggle main sidebar"
9090 />
......@@ -164,10 +164,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
164164 <Sidebar
165165 side="left"
166166 collapsible="offcanvas"
167 className={cn(
168 "border-r border-border text-foreground",
169 useSidebarV2 ? "app-sidebar" : "bg-card",
170 )}
167 data-app-sidebar=""
168 data-sidebar-version={useSidebarV2 ? "v2" : "v1"}
169 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
171170 resizable={{
172171 maxWidth: sidebarMaximumWidth,
173172 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
apps/web/src/components/ChatView.tsx+1
......@@ -5470,6 +5470,7 @@ function ChatViewContent(props: ChatViewProps) {
54705470 )}
54715471 >
54725472 <div
5473 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
54735474 className={cn(
54745475 "chat-composer-lower-chrome relative z-10",
54755476 isGitRepo
apps/web/src/components/CommandPalette.logic.test.ts+28
......@@ -3,10 +3,38 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools
33import type { Thread } from "../types";
44import {
55 buildThreadActionItems,
6 enumerateCommandPaletteItems,
67 filterCommandPaletteGroups,
78 type CommandPaletteGroup,
89} from "./CommandPalette.logic";
910
11describe("enumerateCommandPaletteItems", () => {
12 it("assigns positional jump shortcuts to the first nine displayed items", () => {
13 const items = Array.from({ length: 10 }, (_, index) => ({
14 kind: "action" as const,
15 value: `project-${index + 1}`,
16 searchTerms: [],
17 title: `Project ${index + 1}`,
18 icon: null,
19 shortcutCommand: "chat.new" as const,
20 run: async () => undefined,
21 }));
22
23 expect(enumerateCommandPaletteItems(items).map((item) => item.shortcutCommand)).toEqual([
24 "thread.jump.1",
25 "thread.jump.2",
26 "thread.jump.3",
27 "thread.jump.4",
28 "thread.jump.5",
29 "thread.jump.6",
30 "thread.jump.7",
31 "thread.jump.8",
32 "thread.jump.9",
33 undefined,
34 ]);
35 });
36});
37
1038const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
1139const PROJECT_ID = ProjectId.make("project-1");
1240
apps/web/src/components/CommandPalette.logic.ts+17-1
......@@ -1,4 +1,8 @@
1import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts";
1import {
2 type KeybindingCommand,
3 type FilesystemBrowseEntry,
4 THREAD_JUMP_KEYBINDING_COMMANDS,
5} from "@t3tools/contracts";
26import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
37import * as Arr from "effect/Array";
48import * as Result from "effect/Result";
......@@ -52,6 +56,18 @@ export interface CommandPaletteView {
5256 readonly initialQuery?: string;
5357}
5458
59export function enumerateCommandPaletteItems(
60 items: ReadonlyArray<CommandPaletteActionItem>,
61): CommandPaletteActionItem[] {
62 return items.map((item, index) => {
63 const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index];
64 if (shortcutCommand) return { ...item, shortcutCommand };
65
66 const { shortcutCommand: _shortcutCommand, ...itemWithoutShortcut } = item;
67 return itemWithoutShortcut;
68 });
69}
70
5571export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
5672
5773export function filterBrowseEntries(input: {
apps/web/src/components/CommandPalette.tsx+56-34
......@@ -95,6 +95,7 @@ import {
9595 buildProjectActionItems,
9696 buildRootGroups,
9797 buildThreadActionItems,
98 enumerateCommandPaletteItems,
9899 type CommandPaletteActionItem,
99100 type CommandPaletteSubmenuItem,
100101 type CommandPaletteView,
......@@ -112,7 +113,7 @@ import { ProjectFavicon } from "./ProjectFavicon";
112113import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
113114import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
114115import { resolveDefaultProviderModelSelection } from "../providerInstances";
115import { resolveShortcutCommand } from "../keybindings";
116import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
116117import {
117118 Command,
118119 CommandDialog,
......@@ -686,29 +687,30 @@ function OpenCommandPaletteDialog(props: {
686687
687688 const projectThreadItems = useMemo(
688689 () =>
689 buildProjectActionItems({
690 projects,
691 valuePrefix: "new-thread-in",
692 shortcutCommand: "chat.new",
693 icon: (project) => (
694 <ProjectFavicon
695 environmentId={project.environmentId}
696 cwd={project.workspaceRoot}
697 className={ITEM_ICON_CLASS}
698 />
699 ),
700 runProject: async (project) => {
701 await startNewThreadInProjectFromContext(
702 {
703 activeDraftThread,
704 activeThread: activeThread ?? undefined,
705 defaultProjectRef,
706 handleNewThread,
707 },
708 scopeProjectRef(project.environmentId, project.id),
709 );
710 },
711 }),
690 enumerateCommandPaletteItems(
691 buildProjectActionItems({
692 projects,
693 valuePrefix: "new-thread-in",
694 icon: (project) => (
695 <ProjectFavicon
696 environmentId={project.environmentId}
697 cwd={project.workspaceRoot}
698 className={ITEM_ICON_CLASS}
699 />
700 ),
701 runProject: async (project) => {
702 await startNewThreadInProjectFromContext(
703 {
704 activeDraftThread,
705 activeThread: activeThread ?? undefined,
706 defaultProjectRef,
707 handleNewThread,
708 },
709 scopeProjectRef(project.environmentId, project.id),
710 );
711 },
712 }),
713 ),
712714 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
713715 );
714716
......@@ -997,7 +999,13 @@ function OpenCommandPaletteDialog(props: {
997999 : projectThreadItems;
9981000 pushPaletteView({
9991001 addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
1000 groups: [{ value: "projects", label: "Projects", items: prioritized }],
1002 groups: [
1003 {
1004 value: "projects",
1005 label: "Projects",
1006 items: enumerateCommandPaletteItems(prioritized),
1007 },
1008 ],
10011009 });
10021010 }, [
10031011 clearOpenIntent,
......@@ -1545,6 +1553,22 @@ function OpenCommandPaletteDialog(props: {
15451553 }
15461554
15471555 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
1556 const command = resolveShortcutCommand(event, keybindings, {
1557 platform: navigator.platform,
1558 context: { modelPickerOpen: false },
1559 });
1560 if (threadJumpIndexFromCommand(command ?? "") !== null) {
1561 const matchingItem = displayedGroups
1562 .flatMap((group) => group.items)
1563 .find((item) => item.shortcutCommand === command);
1564 if (matchingItem) {
1565 event.preventDefault();
1566 event.stopPropagation();
1567 executeItem(matchingItem);
1568 return;
1569 }
1570 }
1571
15481572 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
15491573 event.preventDefault();
15501574 void submitAddProjectCloneFlow();
......@@ -1855,7 +1879,7 @@ function OpenCommandPaletteDialog(props: {
18551879 <span className="truncate text-foreground text-sm">
18561880 {remoteProjectContext.title}
18571881 </span>
1858 <span className="truncate text-muted-foreground/70 text-xs">
1882 <span className="truncate text-muted-foreground/85 text-xs">
18591883 {remoteProjectContext.description}
18601884 </span>
18611885 </span>
......@@ -1896,37 +1920,35 @@ function OpenCommandPaletteDialog(props: {
18961920 <Kbd>
18971921 <ArrowDownIcon />
18981922 </Kbd>
1899 <span className={cn("text-muted-foreground/80")}>Navigate</span>
1923 <span>Navigate</span>
19001924 </KbdGroup>
19011925 {addProjectCloneFlow?.step === "repository" ? (
19021926 <KbdGroup className="items-center gap-1.5">
19031927 <Kbd>Enter</Kbd>
1904 <span className={cn("text-muted-foreground/80")}>
1905 {remoteProjectButtonLabel ?? "Continue"}
1906 </span>
1928 <span>{remoteProjectButtonLabel ?? "Continue"}</span>
19071929 </KbdGroup>
19081930 ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? (
19091931 <KbdGroup className="items-center gap-1.5">
19101932 <Kbd>Enter</Kbd>
1911 <span className={cn("text-muted-foreground/80")}>Select</span>
1933 <span>Select</span>
19121934 </KbdGroup>
19131935 ) : null}
19141936 {isSubmenu ? (
19151937 <KbdGroup className="items-center gap-1.5">
19161938 <Kbd>Backspace</Kbd>
1917 <span className={cn("text-muted-foreground/80")}>Back</span>
1939 <span>Back</span>
19181940 </KbdGroup>
19191941 ) : null}
19201942 <KbdGroup className="items-center gap-1.5">
19211943 <Kbd>Esc</Kbd>
1922 <span className={cn("text-muted-foreground/80")}>Close</span>
1944 <span>Close</span>
19231945 </KbdGroup>
19241946 </div>
19251947 {canOpenProjectFromFileManager ? (
19261948 <Button
19271949 variant="ghost"
19281950 size="xs"
1929 className="h-auto px-2 text-xs text-muted-foreground/80 hover:bg-transparent hover:text-foreground"
1951 className="h-auto px-2 text-zinc-700 text-xs hover:bg-transparent hover:text-foreground dark:text-zinc-300"
19301952 disabled={isPickingProjectFolder}
19311953 onClick={() => {
19321954 void handleOpenProjectFromFileManager();
apps/web/src/components/CommandPaletteResults.tsx+2-2
......@@ -75,7 +75,7 @@ function DisabledCommandPaletteResultRow(props: {
7575 {props.item.titleLeadingContent}
7676 <span className="truncate">{props.item.title}</span>
7777 </span>
78 <span className="truncate text-muted-foreground/70 text-xs">
78 <span className="truncate text-muted-foreground/85 text-xs">
7979 {props.item.description}
8080 </span>
8181 </span>
......@@ -121,7 +121,7 @@ function CommandPaletteResultRow(props: {
121121 {props.item.titleLeadingContent}
122122 <span className="truncate">{props.item.title}</span>
123123 </span>
124 <span className="truncate text-muted-foreground/70 text-xs">
124 <span className="truncate text-muted-foreground/85 text-xs">
125125 {props.item.description}
126126 </span>
127127 </span>
apps/web/src/components/RightPanelTabs.tsx+2-2
......@@ -157,7 +157,7 @@ function RightPanelEmptyState(props: {
157157 key={action.label}
158158 type="button"
159159 onClick={action.onClick}
160 className="flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left transition hover:border-border hover:bg-accent/60"
160 className="flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:border-border hover:bg-accent/60 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"
161161 >
162162 {content}
163163 </button>
......@@ -166,7 +166,7 @@ function RightPanelEmptyState(props: {
166166 const disabledCard = (
167167 <button
168168 type="button"
169 className="flex min-h-28 w-full cursor-not-allowed flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left opacity-40"
169 className="flex min-h-28 w-full cursor-not-allowed flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left opacity-40 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"
170170 aria-disabled="true"
171171 >
172172 {content}
apps/web/src/components/Sidebar.tsx+1-1
......@@ -2245,7 +2245,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22452245 <SidebarMenuButton
22462246 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
22472247 size="sm"
2248 className={`gap-2 px-2 py-1.5 pr-8 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground max-sm:pr-14 ${
2248 className={`gap-2 px-2 py-1.5 pr-8 text-left hover:bg-sidebar-row-hover group-hover/project-header:bg-sidebar-row-hover group-hover/project-header:text-sidebar-foreground max-sm:pr-14 ${
22492249 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
22502250 }`}
22512251 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
apps/web/src/components/SidebarV2.tsx+538-425
......@@ -10,13 +10,17 @@ import {
1010import type { ScopedThreadRef } from "@t3tools/contracts";
1111import {
1212 CheckIcon,
13 ChevronDownIcon,
1314 CircleCheckIcon,
1415 CircleDashedIcon,
15 CloudIcon,
16 CircleAlertIcon,
17 FolderIcon,
1618 FolderPlusIcon,
19 GitBranchIcon,
1720 MessageSquareIcon,
1821 PlusIcon,
1922 SearchIcon,
23 ServerIcon,
2024 SquarePenIcon,
2125 Undo2Icon,
2226} from "lucide-react";
......@@ -82,22 +86,22 @@ import {
8286import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
8387import { ProjectFavicon } from "./ProjectFavicon";
8488import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
89import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
8590import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
8691import { primaryServerProvidersAtom } from "../state/server";
8792import { stackedThreadToast, toastManager } from "./ui/toast";
8893import { CommandDialogTrigger } from "./ui/command";
8994import { Kbd } from "./ui/kbd";
95import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
9096import {
9197 SidebarContent,
9298 SidebarGroup,
93 SidebarMenu,
9499 SidebarMenuButton,
95 SidebarMenuItem,
96100 SidebarSeparator,
97101 useSidebar,
98102} from "./ui/sidebar";
99103import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
100import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
104import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
101105
102106// Settled-tail paging: recent history is the common lookup; the deep tail
103107// stays behind an explicit Show more.
......@@ -114,6 +118,110 @@ function threadTimeLabel(thread: SidebarThreadSummary): string {
114118 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
115119}
116120
121function SidebarV2ThreadTooltip({
122 thread,
123 projectTitle,
124 projectCwd,
125 environmentLabel,
126 driverKind,
127 modelInstanceId,
128 modelLabel,
129 status,
130}: {
131 thread: SidebarThreadSummary;
132 projectTitle: string | null;
133 projectCwd: string | null;
134 environmentLabel: string | null;
135 driverKind: ProviderInstanceEntry["driverKind"] | null;
136 modelInstanceId: string;
137 modelLabel: string;
138 status: {
139 label: string;
140 className: string;
141 icon: "working" | "done" | null;
142 } | null;
143}) {
144 return (
145 <TooltipPopup
146 side="right"
147 align="start"
148 sideOffset={8}
149 className="w-80 text-left whitespace-normal shadow-lg/10 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"
150 >
151 <div className="flex w-full flex-col gap-3 p-2">
152 <div className="flex min-w-0 items-start gap-3">
153 <div className="min-w-0 flex-1 text-sm font-medium text-foreground">{thread.title}</div>
154 {status ? (
155 <div
156 className={cn(
157 "inline-flex shrink-0 items-center gap-1 text-xs font-medium",
158 status.className,
159 )}
160 >
161 {status.icon === "working" ? (
162 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
163 ) : status.icon === "done" ? (
164 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
165 ) : null}
166 {status.label}
167 </div>
168 ) : (
169 <div className="shrink-0 tabular-nums text-xs text-muted-foreground">
170 {threadTimeLabel(thread)}
171 </div>
172 )}
173 </div>
174 <div className="grid gap-2 text-xs text-muted-foreground">
175 {projectTitle ? (
176 <div className="flex min-w-0 items-start gap-2">
177 <ProjectFavicon
178 environmentId={thread.environmentId}
179 cwd={projectCwd ?? ""}
180 className="size-4 shrink-0"
181 />
182 <div className="min-w-0 flex-1 wrap-break-word text-foreground/90">
183 {projectTitle}
184 </div>
185 </div>
186 ) : null}
187 {environmentLabel ? (
188 <div className="flex min-w-0 items-start gap-2">
189 <ServerIcon className="size-4 shrink-0 stroke-muted-foreground" />
190 <div className="min-w-0 flex-1 wrap-break-word text-foreground/90">
191 {environmentLabel}
192 </div>
193 </div>
194 ) : null}
195 {thread.branch ? (
196 <div className="flex min-w-0 items-start gap-2">
197 <GitBranchIcon className="size-4 shrink-0 stroke-muted-foreground" />
198 <div className="min-w-0 flex-1 wrap-break-word text-foreground/90">
199 {thread.branch}
200 </div>
201 </div>
202 ) : null}
203 {driverKind ? (
204 <div className="flex min-w-0 items-start gap-2">
205 <ProviderInstanceIcon
206 driverKind={driverKind}
207 displayName={thread.session?.providerName ?? modelInstanceId}
208 iconClassName="size-4 shrink-0"
209 />
210 <div className="min-w-0 flex-1 wrap-break-word text-foreground/90">{modelLabel}</div>
211 </div>
212 ) : null}
213 {thread.session?.lastError ? (
214 <div className="flex min-w-0 items-start gap-2 text-red-600 dark:text-red-400">
215 <CircleAlertIcon className="size-4 shrink-0 stroke-current" />
216 <div className="min-w-0 flex-1 wrap-break-word">{thread.session.lastError}</div>
217 </div>
218 ) : null}
219 </div>
220 </div>
221 </TooltipPopup>
222 );
223}
224
117225const SidebarV2Row = memo(function SidebarV2Row(props: {
118226 thread: SidebarThreadSummary;
119227 variant: "card" | "slim";
......@@ -174,37 +282,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
174282 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
175283 const status = resolveSidebarV2Status(thread);
176284 const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected;
285 // Status hues follow the system-wide convention set by sidebar v1 and the
286 // mobile Live Activity/widgets (amber approval, indigo input, sky working)
287 // so a thread reads the same color everywhere it surfaces.
177288 const topStatus =
178289 status === "working"
179290 ? {
180291 label: "Working",
181292 icon: "working" as const,
182293 className:
183 "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400",
294 "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400",
184295 }
185296 : status === "approval"
186297 ? {
187298 label: "Approval",
188299 icon: null,
189 className: "font-semibold text-amber-700 dark:text-amber-300",
300 className: "text-amber-700 dark:text-amber-300",
190301 }
191302 : status === "input"
192303 ? {
193304 label: "Input",
194305 icon: null,
195 className: "font-semibold text-amber-700 dark:text-amber-300",
306 className: "text-indigo-600 dark:text-indigo-300",
196307 }
197308 : status === "failed"
198309 ? {
199310 label: "Failed",
200311 icon: null,
201 className: "font-semibold text-red-700 dark:text-red-300",
312 className: "text-red-700 dark:text-red-300",
202313 }
203314 : isUnread
204315 ? {
205316 label: "Done",
206317 icon: "done" as const,
207 className: "font-semibold text-emerald-700 dark:text-emerald-300",
318 className: "text-emerald-700 dark:text-emerald-300",
208319 }
209320 : null;
210321
......@@ -227,11 +338,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
227338 }, [onChangeRequestState, prState, threadKey]);
228339
229340 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
230 const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null;
341 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
342 const driverKind = providerEntry?.driverKind ?? null;
343 const selectedModel = providerEntry?.models.find(
344 (model) => model.slug === thread.modelSelection.model,
345 );
346 const modelLabel = selectedModel
347 ? getTriggerDisplayModelLabel(selectedModel)
348 : thread.modelSelection.model;
231349
232350 const isRemote =
233351 props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
234352
353 const detailsTooltip = (
354 <SidebarV2ThreadTooltip
355 thread={thread}
356 projectTitle={props.projectTitle}
357 projectCwd={props.projectCwd}
358 environmentLabel={props.environmentLabel}
359 driverKind={driverKind}
360 modelInstanceId={modelInstanceId}
361 modelLabel={modelLabel}
362 status={topStatus}
363 />
364 );
365
235366 const handleClick = useCallback(
236367 (event: ReactMouseEvent) => {
237368 onThreadClick(event, threadRef);
......@@ -312,13 +443,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
312443 [openPrLink, pr],
313444 );
314445
315 const rowClassName = cn(
316 "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left",
446 // All Sidebar V2 rows share one surface model. Live threads used to look
447 // like elevated cards while settled threads were plain rows, leaving neither
448 // a useful hierarchy nor a reliable hover cue. Status now lives in the row
449 // content; surface is reserved for interaction (hover, multi-select, route).
450 const rowSurfaceClassName = cn(
451 "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none",
317452 props.isActive
318 ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]"
453 ? "bg-sidebar-row-active text-sidebar-foreground dark:inset-ring-1 dark:inset-ring-white/5"
319454 : isSelected
320 ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]"
321 : "hover:bg-accent/65",
455 ? "bg-sidebar-row-selected text-sidebar-foreground dark:inset-ring-1 dark:inset-ring-white/5"
456 : shouldRecede
457 ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
458 : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
322459 );
323460
324461 const title = isRenaming ? (
......@@ -332,30 +469,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
332469 onBlur={handleRenameBlur}
333470 onClick={(event) => event.stopPropagation()}
334471 onDoubleClick={(event) => event.stopPropagation()}
335 className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground"
472 className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground"
336473 />
337474 ) : (
338475 <span
339476 className={cn(
340 "min-w-0 flex-1 text-[13px] leading-5",
477 "min-w-0 flex-1 text-sm",
478 shouldRecede ? "font-normal" : "font-medium",
341479 variant === "card"
342480 ? cn(
343 "line-clamp-2 break-words",
481 "truncate",
344482 isUnread
345 ? "font-semibold text-foreground"
483 ? "text-foreground"
346484 : status !== "ready"
347 ? "font-semibold text-foreground/95"
485 ? "text-foreground/95"
348486 : shouldRecede
349 ? "font-normal text-muted-foreground/75"
350 : "font-medium text-foreground/90",
487 ? "text-muted-foreground/80"
488 : "text-foreground/90",
351489 )
352490 : cn(
353 "truncate transition-colors group-hover/v2-row:text-foreground",
491 "truncate group-hover/v2-row:text-foreground",
354492 props.isActive
355493 ? "text-foreground"
356494 : isUnread
357 ? "font-medium text-muted-foreground"
358 : "text-muted-foreground/60",
495 ? "text-muted-foreground"
496 : "text-muted-foreground/70",
359497 ),
360498 )}
361499 >
......@@ -369,7 +507,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
369507 type="button"
370508 onClick={handlePrClick}
371509 className={cn(
372 "shrink-0 font-mono text-[10px] hover:underline",
510 "shrink-0 font-mono text-xs hover:underline",
373511 variant === "slim" && variantAction === "unsettle"
374512 ? props.isActive
375513 ? "text-muted-foreground/70"
......@@ -388,67 +526,74 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
388526 data-thread-item
389527 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
390528 >
391 <div
392 role="button"
393 tabIndex={0}
394 data-testid="sidebar-v2-row-slim"
395 className={cn(rowClassName, "flex h-[34px] items-center gap-2.5 px-2.5")}
396 onClick={handleClick}
397 onDoubleClick={handleDoubleClick}
398 onKeyDown={handleKeyDown}
399 onContextMenu={handleContextMenu}
400 >
401 {/* Settled history recedes: dimmed favicon at rest, restored on
402 hover so the tail stays scannable when you're hunting. */}
403 <span
404 className={cn(
405 "shrink-0 transition-opacity",
406 !props.isActive &&
407 "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0",
408 )}
529 <Tooltip>
530 <TooltipTrigger
531 render={
532 <div
533 role="button"
534 tabIndex={0}
535 data-testid="sidebar-v2-row-slim"
536 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
537 onClick={handleClick}
538 onDoubleClick={handleDoubleClick}
539 onKeyDown={handleKeyDown}
540 onContextMenu={handleContextMenu}
541 />
542 }
409543 >
410 <ProjectFavicon
411 environmentId={thread.environmentId}
412 cwd={props.projectCwd ?? ""}
413 className="size-3.5"
414 fallbackIcon={MessageSquareIcon}
415 />
416 </span>
417 {title}
418 {/* The PR badge stays outside the hover-fading slot: it must
544 {/* Settled history recedes: dimmed favicon at rest, restored on
545 hover so the tail stays scannable when you're hunting. */}
546 <span
547 className={cn(
548 "shrink-0 transition-opacity",
549 !props.isActive &&
550 "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0",
551 )}
552 >
553 <ProjectFavicon
554 environmentId={thread.environmentId}
555 cwd={props.projectCwd ?? ""}
556 className="size-4"
557 fallbackIcon={MessageSquareIcon}
558 />
559 </span>
560 {title}
561 {/* The PR badge stays outside the hover-fading slot: it must
419562 remain visible AND clickable while the row is hovered. Only
420563 the time/jump label yields to the settle affordance. */}
421 {prBadge}
422 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
423 <span className="inline-flex justify-end tabular-nums text-muted-foreground/40 transition-opacity group-hover/v2-row:opacity-0">
424 <span className="text-[13px]">
425 {props.jumpLabel ??
426 compactSidebarTimeLabel(
427 formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt),
428 )}
564 {prBadge}
565 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
566 <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
567 <span className="text-xs">
568 {props.jumpLabel ??
569 compactSidebarTimeLabel(
570 formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt),
571 )}
572 </span>
429573 </span>
574 {!props.settlementSupported ? null : variantAction === "unsettle" ? (
575 <button
576 type="button"
577 aria-label="Un-settle thread"
578 onClick={handleUnsettleClick}
579 className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5"
580 >
581 <Undo2Icon className="size-3" />
582 </button>
583 ) : (
584 <button
585 type="button"
586 aria-label="Settle thread"
587 onClick={handleSettleClick}
588 className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5"
589 >
590 <CheckIcon className="size-3" />
591 </button>
592 )}
430593 </span>
431 {!props.settlementSupported ? null : variantAction === "unsettle" ? (
432 <button
433 type="button"
434 aria-label="Un-settle thread"
435 onClick={handleUnsettleClick}
436 className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
437 >
438 <Undo2Icon className="size-3" />
439 </button>
440 ) : (
441 <button
442 type="button"
443 aria-label="Settle thread"
444 onClick={handleSettleClick}
445 className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
446 >
447 <CheckIcon className="size-3" />
448 </button>
449 )}
450 </span>
451 </div>
594 </TooltipTrigger>
595 {detailsTooltip}
596 </Tooltip>
452597 </li>
453598 );
454599 }
......@@ -460,144 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
460605 data-thread-item
461606 className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]"
462607 >
463 <div
464 role="button"
465 tabIndex={0}
466 data-testid="sidebar-v2-row-card"
467 className={cn(
468 // Every card carries a faint tonal fill so threads read as
469 // discrete objects; active/selected/hover are brighter tones of
470 // the same treatment rather than a different shape.
471 "group/v2-row relative w-full cursor-pointer select-none overflow-hidden rounded-lg text-left transition-colors",
472 props.isActive
473 ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]"
474 : isSelected
475 ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]"
476 : shouldRecede
477 ? "bg-foreground/[0.025] hover:bg-accent/45 dark:bg-white/[0.025]"
478 : "bg-foreground/[0.035] hover:bg-accent/65 dark:bg-white/[0.035]",
479 )}
480 onClick={handleClick}
481 onDoubleClick={handleDoubleClick}
482 onKeyDown={handleKeyDown}
483 onContextMenu={handleContextMenu}
484 >
485 <div className="relative z-10 px-2.5 py-2">
486 <div className="flex h-5 min-w-0 items-center gap-1.5">
487 <ProjectFavicon
488 environmentId={thread.environmentId}
489 cwd={props.projectCwd ?? ""}
490 className="size-3.5 shrink-0"
608 <Tooltip>
609 <TooltipTrigger
610 render={
611 <div
612 role="button"
613 tabIndex={0}
614 data-testid="sidebar-v2-row-card"
615 className={rowSurfaceClassName}
616 onClick={handleClick}
617 onDoubleClick={handleDoubleClick}
618 onKeyDown={handleKeyDown}
619 onContextMenu={handleContextMenu}
491620 />
492 {props.projectTitle ? (
493 <span
494 className={cn(
495 "min-w-0 flex-1 truncate text-[13px] leading-5 text-muted-foreground/70",
496 isUnread || status !== "ready"
497 ? "font-semibold"
498 : shouldRecede
499 ? "font-normal"
500 : "font-medium",
501 )}
502 >
503 {props.projectTitle}
504 </span>
505 ) : (
506 <span className="flex-1" />
507 )}
508 <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-[13px]">
509 <span className="tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
510 {props.jumpLabel ? (
511 props.jumpLabel
512 ) : topStatus ? (
513 <span
514 role="status"
515 className={cn(
516 "inline-flex items-center gap-1 text-[11px]",
517 topStatus.className,
518 )}
621 }
622 >
623 <div className="relative z-10 h-[4.875rem] px-2.5 py-2">
624 <div className="flex h-5 min-w-0 items-center gap-1.5">
625 <ProjectFavicon
626 environmentId={thread.environmentId}
627 cwd={props.projectCwd ?? ""}
628 className="size-4 shrink-0"
629 />
630 {props.projectTitle ? (
631 <span className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground/85">
632 {props.projectTitle}
633 </span>
634 ) : (
635 <span className="flex-1" />
636 )}
637 <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs">
638 <span className="tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0">
639 {props.jumpLabel ? (
640 props.jumpLabel
641 ) : topStatus ? (
642 <span
643 role="status"
644 className={cn(
645 "inline-flex items-center gap-1 font-medium",
646 topStatus.className,
647 )}
648 >
649 {topStatus.icon === "working" ? (
650 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
651 ) : topStatus.icon === "done" ? (
652 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
653 ) : null}
654 {topStatus.label}
655 </span>
656 ) : (
657 threadTimeLabel(thread)
658 )}
659 </span>
660 {props.settlementSupported ? (
661 <button
662 type="button"
663 aria-label="Settle thread"
664 onClick={handleSettleClick}
665 className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5"
519666 >
520 {topStatus.icon === "working" ? (
521 <CircleDashedIcon aria-hidden className="size-3" />
522 ) : topStatus.icon === "done" ? (
523 <CircleCheckIcon aria-hidden className="size-3" />
524 ) : null}
525 {topStatus.label}
526 </span>
527 ) : (
528 threadTimeLabel(thread)
529 )}
667 <CheckIcon className="size-3" />
668 Settle
669 </button>
670 ) : null}
530671 </span>
531 {props.settlementSupported ? (
532 <button
533 type="button"
534 aria-label="Settle thread"
535 onClick={handleSettleClick}
536 className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[10px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
537 >
538 <CheckIcon className="size-3" />
539 Settle
540 </button>
672 </div>
673 <div className="mt-1 flex min-w-0">{title}</div>
674 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">
675 {thread.branch ? (
676 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
677 ) : (
678 <span className="flex-1" />
679 )}
680 {prBadge}
681 {diff ? (
682 <span className="shrink-0 font-mono">
683 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
684 <span className="text-red-600 dark:text-red-400">−{diff.deletions}</span>
685 </span>
541686 ) : null}
542 </span>
543 </div>
544 <div className="mt-1 flex min-w-0">{title}</div>
545 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground/65">
546 {thread.branch ? (
547 <span className="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono [mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-1rem),transparent_100%)] [-webkit-mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-1rem),transparent_100%)]">
548 {thread.branch}
549 </span>
550 ) : (
551 <span className="flex-1" />
552 )}
553 {prBadge}
554 {diff ? (
555 <span className="shrink-0 font-mono">
556 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
557 <span className="text-red-600 dark:text-red-400">−{diff.deletions}</span>
558 </span>
559 ) : null}
560 <span className="ml-auto inline-flex shrink-0 items-center gap-1">
561 {driverKind ? (
562 <Tooltip>
563 <TooltipTrigger
564 render={<span className="inline-flex shrink-0 items-center opacity-60" />}
687 <span className="ml-auto inline-flex shrink-0 items-center gap-1">
688 {isRemote ? (
689 <span
690 aria-label={props.environmentLabel ?? "Remote environment"}
691 className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70"
565692 >
693 <ServerIcon aria-hidden className="size-4" />
694 </span>
695 ) : null}
696 {driverKind ? (
697 <span className="inline-flex shrink-0 items-center opacity-60">
566698 <ProviderInstanceIcon
567699 driverKind={driverKind}
568700 displayName={thread.session?.providerName ?? modelInstanceId}
569 iconClassName="size-3"
701 iconClassName="size-4"
570702 />
571 </TooltipTrigger>
572 <TooltipPopup side="top">{thread.modelSelection.model}</TooltipPopup>
573 </Tooltip>
574 ) : null}
575 {isRemote ? (
576 <Tooltip>
577 <TooltipTrigger
578 render={
579 <span className="inline-flex shrink-0 items-center text-muted-foreground/50" />
580 }
581 >
582 <CloudIcon className="size-3" />
583 </TooltipTrigger>
584 <TooltipPopup side="top">
585 Running on {props.environmentLabel ?? "a remote environment"}
586 </TooltipPopup>
587 </Tooltip>
588 ) : null}
589 </span>
590 </div>
591 {status === "failed" && thread.session?.lastError ? (
592 <div
593 className="mt-0.5 min-w-0 truncate text-[10px] text-red-600/75 dark:text-red-400/70"
594 title={thread.session.lastError}
595 >
596 {thread.session.lastError}
703 </span>
704 ) : null}
705 </span>
597706 </div>
598 ) : null}
599 </div>
600 </div>
707 </div>
708 </TooltipTrigger>
709 {detailsTooltip}
710 </Tooltip>
601711 </li>
602712 );
603713});
......@@ -711,9 +821,8 @@ export default function SidebarV2() {
711821 [],
712822 );
713823
714 // Project scope: chips above the list. Scoping filters the list AND
715 // becomes the new-thread target — one visible control doing both jobs the
716 // old per-project headers did.
824 // Project scope: one menu above the list. Scoping filters the list without
825 // making the header width depend on the number or length of project names.
717826 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
718827 const scopedProject = useMemo(
719828 () =>
......@@ -794,7 +903,7 @@ export default function SidebarV2() {
794903 // filter context changes so a scope/search flip never inherits a deep
795904 // page state.
796905 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
797 const settledResetKey = `${projectScopeKey ?? "all"}`;
906 const settledResetKey = projectScopeKey ?? "all";
798907 const lastSettledResetKeyRef = useRef(settledResetKey);
799908 if (lastSettledResetKeyRef.current !== settledResetKey) {
800909 lastSettledResetKeyRef.current = settledResetKey;
......@@ -1322,243 +1431,245 @@ export default function SidebarV2() {
13221431 const newThreadShortcutLabel =
13231432 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
13241433 shortcutLabelForCommand(keybindings, "chat.new");
1325 const projectScrollerRef = useRef<HTMLDivElement>(null);
1326 const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false);
1327 const updateProjectScrollFade = useCallback(() => {
1328 const scroller = projectScrollerRef.current;
1329 if (!scroller) return;
1330 setCanScrollProjectsRight(
1331 scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1,
1332 );
1333 }, []);
1334 useEffect(() => {
1335 const scroller = projectScrollerRef.current;
1336 if (!scroller) return;
1337
1338 updateProjectScrollFade();
1339 const resizeObserver = new ResizeObserver(updateProjectScrollFade);
1340 resizeObserver.observe(scroller);
1341 return () => resizeObserver.disconnect();
1342 }, [projects, updateProjectScrollFade]);
1343
13441434 return (
13451435 <>
13461436 <SidebarChromeHeader isElectron={isElectron} />
13471437 <SidebarContent className="gap-0">
13481438 <SidebarGroup className="px-2 pb-2 pt-3">
1349 <SidebarMenu className="flex-row gap-1">
1350 <SidebarMenuItem className="min-w-0 flex-1">
1439 <div className="flex items-center gap-1">
1440 <div className="min-w-0 flex-1">
13511441 <CommandDialogTrigger
13521442 render={
13531443 <SidebarMenuButton
13541444 size="sm"
1355 className="gap-2 border border-border bg-background/60 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:ring-0"
1445 type="button"
1446 aria-label="Search threads and commands"
1447 className="h-8 gap-2 rounded-md border-0 bg-transparent px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
13561448 data-testid="command-palette-trigger"
13571449 />
13581450 }
13591451 >
1360 <SearchIcon className="size-3.5 text-muted-foreground/70" />
1361 <span className="flex-1 truncate text-left text-xs">Search</span>
1452 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1453 <div className="flex-1 truncate text-left">Search</div>
13621454 {commandPaletteShortcutLabel ? (
1363 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
1455 <Kbd className="h-4 min-w-0 rounded-sm bg-sidebar-control-surface px-1.5 text-[10px] text-sidebar-muted-foreground ring-1 ring-sidebar-border">
13641456 {commandPaletteShortcutLabel}
13651457 </Kbd>
13661458 ) : null}
13671459 </CommandDialogTrigger>
1368 </SidebarMenuItem>
1369 <SidebarMenuItem className="shrink-0">
1370 <SidebarMenuButton
1371 size="sm"
1372 className="size-7 justify-center border border-border bg-background/60 p-0 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1373 onClick={handleNewThreadClick}
1374 disabled={projects.length === 0}
1375 aria-label="New thread"
1376 tooltip={{
1377 children: newThreadShortcutLabel
1378 ? `New thread (${newThreadShortcutLabel})`
1379 : "New thread",
1380 side: "right",
1381 }}
1382 >
1383 <SquarePenIcon className="size-3.5 text-muted-foreground/70" />
1384 </SidebarMenuButton>
1385 </SidebarMenuItem>
1386 </SidebarMenu>
1460 </div>
1461 <div className="shrink-0">
1462 <Tooltip>
1463 <TooltipTrigger
1464 render={
1465 <SidebarMenuButton
1466 size="sm"
1467 type="button"
1468 className="relative size-8 justify-center rounded-md border-0 bg-transparent p-0 text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
1469 onClick={handleNewThreadClick}
1470 disabled={projects.length === 0}
1471 aria-label="New thread"
1472 />
1473 }
1474 >
1475 <SquarePenIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1476 <span
1477 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
1478 aria-hidden="true"
1479 />
1480 </TooltipTrigger>
1481 <TooltipPopup side="right">
1482 {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
1483 </TooltipPopup>
1484 </Tooltip>
1485 </div>
1486 </div>
13871487 </SidebarGroup>
13881488 {projects.length > 0 ? (
1389 <SidebarGroup className="px-2 pb-1 pt-1">
1390 <div className="relative">
1391 <div
1392 ref={projectScrollerRef}
1393 onScroll={updateProjectScrollFade}
1394 className={cn(
1395 "flex items-center gap-1 overflow-x-auto pr-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
1396 canScrollProjectsRight &&
1397 "[mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-4.5rem),transparent_calc(100%-2rem))] [-webkit-mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-4.5rem),transparent_calc(100%-2rem))]",
1398 )}
1399 role="tablist"
1400 aria-label="Filter threads by project"
1401 >
1402 {projects.length > 1 ? (
1403 <button
1404 type="button"
1405 role="tab"
1406 aria-selected={projectScopeKey === null}
1407 onClick={() => setProjectScopeKey(null)}
1408 className={cn(
1409 "shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors",
1410 projectScopeKey === null
1411 ? "border-foreground/15 bg-accent text-foreground"
1412 : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40",
1413 )}
1414 >
1415 All
1416 </button>
1417 ) : null}
1418 {projects.map((project) => {
1419 const scopeKey = `${project.environmentId}:${project.id}`;
1420 const isScoped = projectScopeKey === scopeKey;
1421 return (
1422 <button
1423 key={scopeKey}
1424 type="button"
1425 role="tab"
1426 aria-selected={isScoped}
1427 onClick={() => setProjectScopeKey(isScoped ? null : scopeKey)}
1428 className={cn(
1429 "flex shrink-0 items-center gap-1.5 rounded-md border py-1 pl-1.5 pr-2.5 text-[11px] font-medium transition-colors",
1430 isScoped
1431 ? "border-foreground/15 bg-accent text-foreground"
1432 : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40",
1433 )}
1434 >
1435 <ProjectFavicon
1436 environmentId={project.environmentId}
1437 cwd={project.workspaceRoot}
1438 className="size-3.5"
1439 />
1440 <span className="max-w-28 truncate">{project.title}</span>
1441 </button>
1442 );
1443 })}
1444 </div>
1445 <div className="pointer-events-none absolute inset-y-0 right-0 flex w-12 items-center justify-end bg-gradient-to-r from-transparent via-card/90 to-card">
1446 <Tooltip>
1447 <TooltipTrigger
1448 render={
1449 <button
1450 type="button"
1451 aria-label="Add project"
1452 onClick={openAddProjectCommandPalette}
1453 className="pointer-events-auto flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-card text-muted-foreground/70 shadow-sm transition-colors hover:bg-accent hover:text-foreground"
1454 />
1489 <SidebarGroup className="px-2 pb-2 pt-0">
1490 <div className="flex items-center gap-1">
1491 <Menu>
1492 <MenuTrigger
1493 aria-label="Filter threads by project"
1494 className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm font-medium text-sidebar-muted-foreground outline-none hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
1495 >
1496 {scopedProject ? (
1497 <ProjectFavicon
1498 environmentId={scopedProject.environmentId}
1499 cwd={scopedProject.workspaceRoot}
1500 className="size-4 shrink-0"
1501 />
1502 ) : (
1503 <FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1504 )}
1505 <span className="min-w-0 flex-1 truncate">
1506 {scopedProject?.title ?? "All projects"}
1507 </span>
1508 <ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" />
1509 </MenuTrigger>
1510 <MenuPopup align="start" className="w-(--anchor-width)">
1511 <MenuRadioGroup
1512 value={projectScopeKey ?? "all"}
1513 onValueChange={(value) =>
1514 setProjectScopeKey(value === "all" ? null : (value as string))
14551515 }
14561516 >
1457 <FolderPlusIcon className="size-3.5" />
1458 </TooltipTrigger>
1459 <TooltipPopup side="bottom">Add project</TooltipPopup>
1460 </Tooltip>
1461 </div>
1517 <MenuRadioItem
1518 value="all"
1519 closeOnClick
1520 className="[&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
1521 >
1522 <FolderIcon className="size-3.5" />
1523 <span className="min-w-0 truncate">All projects</span>
1524 </MenuRadioItem>
1525 {projects.map((project) => {
1526 const scopeKey = `${project.environmentId}:${project.id}`;
1527 return (
1528 <MenuRadioItem
1529 key={scopeKey}
1530 value={scopeKey}
1531 closeOnClick
1532 className="[&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
1533 >
1534 <ProjectFavicon
1535 environmentId={project.environmentId}
1536 cwd={project.workspaceRoot}
1537 className="size-3.5"
1538 />
1539 <span className="min-w-0 truncate">{project.title}</span>
1540 </MenuRadioItem>
1541 );
1542 })}
1543 </MenuRadioGroup>
1544 </MenuPopup>
1545 </Menu>
1546 <Tooltip>
1547 <TooltipTrigger
1548 render={
1549 <SidebarMenuButton
1550 size="sm"
1551 className="relative size-8 shrink-0 justify-center rounded-md bg-transparent p-0 text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
1552 onClick={openAddProjectCommandPalette}
1553 type="button"
1554 aria-label="New project"
1555 />
1556 }
1557 >
1558 <FolderPlusIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1559 <span
1560 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
1561 aria-hidden="true"
1562 />
1563 </TooltipTrigger>
1564 <TooltipPopup side="right">New project</TooltipPopup>
1565 </Tooltip>
14621566 </div>
14631567 </SidebarGroup>
14641568 ) : null}
14651569 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">
1466 <ul ref={attachListAutoAnimateRef} className="flex flex-col gap-px">
1467 {orderedThreads.flatMap((thread, threadIndex) => {
1468 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1469 const isSettledRow = settledThreadKeys.has(threadKey);
1470 // Settled is the ONLY thing that collapses a row: every
1471 // not-settled thread is a full card. Density comes from users
1472 // (or the auto rules) actually settling work, not from the
1473 // sidebar second-guessing what still matters.
1474 const isCard = !isSettledRow;
1475 const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null;
1476 const previousWasCard =
1477 previousThread != null &&
1478 !settledThreadKeys.has(
1479 scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)),
1570 <TooltipProvider
1571 key="sidebar-thread-tooltips-150"
1572 delay={150}
1573 closeDelay={0}
1574 timeout={400}
1575 >
1576 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
1577 {orderedThreads.flatMap((thread, threadIndex) => {
1578 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1579 const isSettledRow = settledThreadKeys.has(threadKey);
1580 // Settled is the ONLY thing that collapses a row: every
1581 // not-settled thread is a full card. Density comes from users
1582 // (or the auto rules) actually settling work, not from the
1583 // sidebar second-guessing what still matters.
1584 const isCard = !isSettledRow;
1585 const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null;
1586 const previousWasCard =
1587 previousThread != null &&
1588 !settledThreadKeys.has(
1589 scopedThreadKey(
1590 scopeThreadRef(previousThread.environmentId, previousThread.id),
1591 ),
1592 );
1593 const showSettledGap = !isCard && previousWasCard;
1594 const row = (
1595 <SidebarV2Row
1596 // Keyed per variant on purpose: when a thread settles, the
1597 // card fades out in place and the slim row fades in at its
1598 // settled position instead of one element FLIP-sliding
1599 // through every row in between (rows here are translucent,
1600 // so a crossing row reads as text painted over text).
1601 key={`${threadKey}:${isCard ? "card" : "slim"}`}
1602 thread={thread}
1603 variant={isCard ? "card" : "slim"}
1604 // Every settled row can un-settle: explicit settles clear
1605 // the override, auto-settled rows get pinned active.
1606 variantAction={isSettledRow ? "unsettle" : "settle"}
1607 settlementSupported={
1608 serverConfigs.get(thread.environmentId)?.environment.capabilities
1609 .threadSettlement === true
1610 }
1611 isActive={routeThreadKey === threadKey}
1612 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
1613 currentEnvironmentId={primaryEnvironmentId}
1614 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
1615 projectCwd={
1616 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1617 }
1618 projectTitle={
1619 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1620 }
1621 providerEntryByInstanceId={providerEntryByInstanceId}
1622 onThreadClick={handleThreadClick}
1623 onThreadActivate={navigateToThread}
1624 onStartRename={startThreadRename}
1625 onRenameTitleChange={setRenamingTitle}
1626 onCommitRename={commitThreadRename}
1627 onCancelRename={cancelThreadRename}
1628 isRenaming={renamingThreadKey === threadKey}
1629 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
1630 onContextMenu={handleThreadContextMenu}
1631 onSettle={attemptSettle}
1632 onUnsettle={attemptUnsettle}
1633 onChangeRequestState={handleChangeRequestState}
1634 />
14801635 );
1481 const showSettledGap = !isCard && previousWasCard;
1482 const row = (
1483 <SidebarV2Row
1484 // Keyed per variant on purpose: when a thread settles, the
1485 // card fades out in place and the slim row fades in at its
1486 // settled position instead of one element FLIP-sliding
1487 // through every row in between (rows here are translucent,
1488 // so a crossing row reads as text painted over text).
1489 key={`${threadKey}:${isCard ? "card" : "slim"}`}
1490 thread={thread}
1491 variant={isCard ? "card" : "slim"}
1492 // Every settled row can un-settle: explicit settles clear
1493 // the override, auto-settled rows get pinned active.
1494 variantAction={isSettledRow ? "unsettle" : "settle"}
1495 settlementSupported={
1496 serverConfigs.get(thread.environmentId)?.environment.capabilities
1497 .threadSettlement === true
1498 }
1499 isActive={routeThreadKey === threadKey}
1500 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
1501 currentEnvironmentId={primaryEnvironmentId}
1502 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
1503 projectCwd={
1504 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1505 }
1506 projectTitle={
1507 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1508 }
1509 providerEntryByInstanceId={providerEntryByInstanceId}
1510 onThreadClick={handleThreadClick}
1511 onThreadActivate={navigateToThread}
1512 onStartRename={startThreadRename}
1513 onRenameTitleChange={setRenamingTitle}
1514 onCommitRename={commitThreadRename}
1515 onCancelRename={cancelThreadRename}
1516 isRenaming={renamingThreadKey === threadKey}
1517 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
1518 onContextMenu={handleThreadContextMenu}
1519 onSettle={attemptSettle}
1520 onUnsettle={attemptUnsettle}
1521 onChangeRequestState={handleChangeRequestState}
1522 />
1523 );
1524 if (!showSettledGap) return [row];
1525 // The divider is its own keyed list item (not part of the first
1526 // settled row): it keeps one stable DOM node at the boundary,
1527 // so settling a thread slides it instead of teleporting it
1528 // along with whichever row happens to be first in the tail —
1529 // and row heights stay independent of neighbor classification.
1530 return [
1531 <li
1532 key="settled-divider"
1533 aria-hidden
1534 data-thread-selection-safe
1535 className="list-none"
1536 >
1537 <div className="mb-1 mt-3 flex items-center gap-2 px-2.5">
1538 <span className="text-[10px] font-medium text-muted-foreground/50">
1539 Settled
1636 if (!showSettledGap) return [row];
1637 // The divider is its own keyed list item (not part of the first
1638 // settled row): it keeps one stable DOM node at the boundary,
1639 // so settling a thread slides it instead of teleporting it
1640 // along with whichever row happens to be first in the tail —
1641 // and row heights stay independent of neighbor classification.
1642 return [
1643 <li
1644 key="settled-divider"
1645 aria-hidden
1646 data-thread-selection-safe
1647 className="list-none"
1648 >
1649 <div className="mb-1 mt-3 flex items-center gap-2 px-2.5">
1650 <span className="text-xs font-medium text-muted-foreground/50">Settled</span>
1651 <span className="h-px flex-1 bg-sidebar-border/60" />
1652 </div>
1653 </li>,
1654 row,
1655 ];
1656 })}
1657 {hiddenSettledCount > 0 ? (
1658 <li className="list-none">
1659 <button
1660 type="button"
1661 onClick={showMoreSettled}
1662 className="mt-1 flex h-[30px] w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border font-mono text-[11px] text-muted-foreground transition-colors hover:border-solid hover:border-input hover:bg-background/45 hover:text-foreground dark:border-white/15 dark:hover:border-white/30 dark:hover:bg-transparent"
1663 >
1664 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
1665 <span className="text-muted-foreground/50">
1666 ({hiddenSettledCount} settled hidden)
15401667 </span>
1541 <span className="h-px flex-1 bg-border/60" />
1542 </div>
1543 </li>,
1544 row,
1545 ];
1546 })}
1547 {hiddenSettledCount > 0 ? (
1548 <li className="list-none">
1549 <button
1550 type="button"
1551 onClick={showMoreSettled}
1552 className="mt-1 flex h-[30px] w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-black/15 font-mono text-[11px] text-muted-foreground transition-colors hover:border-solid hover:border-black/30 hover:text-foreground dark:border-white/15 dark:hover:border-white/30"
1553 >
1554 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
1555 <span className="text-muted-foreground/50">
1556 ({hiddenSettledCount} settled hidden)
1557 </span>
1558 </button>
1559 </li>
1560 ) : null}
1561 </ul>
1668 </button>
1669 </li>
1670 ) : null}
1671 </ul>
1672 </TooltipProvider>
15621673 {orderedThreads.length === 0 ? (
15631674 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
15641675 {projects.length === 0 ? (
......@@ -1567,7 +1678,7 @@ export default function SidebarV2() {
15671678 <button
15681679 type="button"
15691680 onClick={openAddProjectCommandPalette}
1570 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
1681 className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
15711682 >
15721683 <PlusIcon className="size-3" />
15731684 Add project
......@@ -1582,7 +1693,9 @@ export default function SidebarV2() {
15821693 ) : null}
15831694 </SidebarGroup>
15841695 </SidebarContent>
1585 <SidebarSeparator />
1696 <div className="shrink-0 px-2">
1697 <SidebarSeparator className="mx-0 w-full bg-sidebar-border/80" />
1698 </div>
15861699 <SidebarChromeFooter />
15871700 </>
15881701 );
apps/web/src/components/chat/ChatComposer.tsx+1-1
......@@ -2165,7 +2165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21652165 "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40",
21662166 isDragOverComposer
21672167 ? "border-primary/70 bg-accent/45"
2168 : "border-black/12 dark:border-white/12",
2168 : "border-black/12 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5",
21692169 projectSelectionRequired ? "opacity-75" : null,
21702170 composerProviderState.composerSurfaceClassName,
21712171 )}
apps/web/src/components/chat/ModelPickerContent.tsx+3-3
......@@ -521,7 +521,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
521521 return (
522522 <TooltipProvider delay={0}>
523523 <div
524 className="relative flex h-screen max-h-96 w-screen max-w-100 flex-row overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"
524 className="relative flex h-screen max-h-96 w-screen max-w-100 flex-row overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5 dark:before:shadow-none"
525525 data-model-picker-content="true"
526526 >
527527 {/* Sidebar */}
......@@ -580,11 +580,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
580580 <ComboboxInput
581581 ref={searchInputRef}
582582 className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5"
583 inputClassName="rounded-none bg-transparent text-sm"
583 inputClassName="rounded-none bg-transparent text-sm placeholder:text-muted-foreground/80"
584584 placeholder="Search models..."
585585 showTrigger={false}
586586 startAddon={
587 <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" />
587 <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/80" />
588588 }
589589 value={searchQuery}
590590 onChange={(e) => setSearchQuery(e.target.value)}
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+1-1
......@@ -28,7 +28,7 @@ function ConfiguredT3ConnectSidebarAvatar() {
2828 appearance={{
2929 elements: {
3030 avatarBox: "size-7",
31 userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent",
31 userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-row-hover",
3232 },
3333 }}
3434 >
apps/web/src/components/settings/SettingsSidebarNav.tsx+5-5
......@@ -85,16 +85,16 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
8585 isActive={isActive}
8686 className={
8787 isActive
88 ? "gap-2.5 px-2.5 py-2 text-left text-[13px] font-medium text-foreground"
89 : "gap-2.5 px-2.5 py-2 text-left text-[13px] text-muted-foreground/70 hover:text-foreground/80"
88 ? "gap-2.5 px-2.5 py-2 text-left text-[13px] font-medium text-sidebar-foreground"
89 : "gap-2.5 px-2.5 py-2 text-left text-[13px] text-sidebar-muted-foreground hover:text-sidebar-foreground"
9090 }
9191 onClick={() => handleSectionClick(item.to)}
9292 >
9393 <Icon
9494 className={
9595 isActive
96 ? "size-4 shrink-0 text-foreground"
97 : "size-4 shrink-0 text-muted-foreground/60"
96 ? "size-4 shrink-0 text-sidebar-foreground"
97 : "size-4 shrink-0 text-sidebar-muted-foreground"
9898 }
9999 />
100100 <span className="truncate">{item.label}</span>
......@@ -114,7 +114,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
114114 <SidebarMenuItem>
115115 <SidebarMenuButton
116116 size="sm"
117 className="gap-2 px-2 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
117 className="gap-2 px-2 py-2 text-xs text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
118118 onClick={handleBackClick}
119119 >
120120 <ArrowLeftIcon className="size-4" />
apps/web/src/components/settings/settingsLayout.tsx+1-1
......@@ -38,7 +38,7 @@ export function SettingsSection({
3838 </h2>
3939 <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div>
4040 </div>
41 <div className="relative overflow-visible rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
41 <div className="relative overflow-visible rounded-2xl border bg-surface-raised text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
4242 {children}
4343 </div>
4444 </section>
apps/web/src/components/sidebar/SidebarChrome.tsx+5-4
......@@ -39,7 +39,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3939 <SidebarTrigger
4040 className={cn(
4141 "relative z-10 md:hidden",
42 backdropVariant && "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!",
42 backdropVariant &&
43 "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!",
4344 )}
4445 />
4546 <SidebarBrand onBackdrop={backdropVariant !== null} />
......@@ -114,11 +115,11 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
114115 <SidebarMenuItem>
115116 <SidebarMenuButton
116117 size="sm"
117 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
118 className="gap-2 px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
118119 onClick={handleSettingsClick}
119120 >
120 <SettingsIcon className="size-3.5" />
121 <span className="text-xs">Settings</span>
121 <SettingsIcon className="size-4 shrink-0" />
122 <span>Settings</span>
122123 </SidebarMenuButton>
123124 </SidebarMenuItem>
124125 </SidebarMenu>
apps/web/src/components/ui/command.tsx+5-5
......@@ -66,7 +66,7 @@ function CommandDialogPopup({
6666 <CommandDialogViewport>
6767 <CommandDialogPrimitive.Popup
6868 className={cn(
69 "pointer-events-auto -translate-y-[calc(1.25rem*var(--nested-dialogs))] relative row-start-2 flex max-h-105 min-h-0 w-full min-w-0 max-w-xl scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border bg-popover not-dark:bg-clip-padding text-popover-foreground opacity-[calc(1-0.1*var(--nested-dialogs))] shadow-lg/5 outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:bg-muted/72 before:shadow-[0_1px_--theme(--color-black/4%)] data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=scroll-area-viewport]:data-has-overflow-y:pe-1 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
69 "pointer-events-auto -translate-y-[calc(1.25rem*var(--nested-dialogs))] relative row-start-2 flex max-h-105 min-h-0 w-full min-w-0 max-w-xl scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border bg-popover not-dark:bg-clip-padding text-popover-foreground opacity-[calc(1-0.1*var(--nested-dialogs))] shadow-lg/5 outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:bg-muted/72 before:shadow-[0_1px_--theme(--color-black/4%)] data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=scroll-area-viewport]:data-has-overflow-y:pe-1 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5 dark:before:shadow-none",
7070 className,
7171 )}
7272 data-slot="command-dialog-popup"
......@@ -108,7 +108,7 @@ function CommandInput({
108108 <AutocompleteInput
109109 autoFocus
110110 className={cn(
111 "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0",
111 "border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0 placeholder:text-muted-foreground/80",
112112 className,
113113 )}
114114 placeholder={placeholder}
......@@ -144,7 +144,7 @@ function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
144144 return (
145145 <div
146146 className={cn(
147 "-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 overflow-hidden rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2 [touch-action:pan-y]",
147 "-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 overflow-hidden rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2 [touch-action:pan-y] dark:border-white/5 dark:shadow-none",
148148 className,
149149 )}
150150 {...props}
......@@ -192,7 +192,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
192192 return (
193193 <kbd
194194 className={cn(
195 "ms-auto font-medium font-sans text-muted-foreground/72 text-xs tracking-widest",
195 "ms-auto font-medium font-sans text-muted-foreground text-xs tracking-widest",
196196 className,
197197 )}
198198 data-slot="command-shortcut"
......@@ -205,7 +205,7 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {
205205 return (
206206 <div
207207 className={cn(
208 "flex items-center justify-between gap-2 rounded-b-[calc(var(--radius-2xl)-1px)] border-t px-5 py-3 text-muted-foreground text-xs",
208 "relative flex items-center justify-between gap-2 rounded-b-[calc(var(--radius-2xl)-1px)] border-t border-zinc-200/80 px-5 py-3 font-medium text-sm text-zinc-700 dark:border-white/8 dark:text-zinc-300 [&_[data-slot=kbd-group]]:font-sans [&_[data-slot=kbd]]:bg-zinc-100 [&_[data-slot=kbd]]:text-zinc-900 [&_[data-slot=kbd]]:ring-1 [&_[data-slot=kbd]]:ring-zinc-200/80 [&_[data-slot=kbd]]:dark:bg-zinc-800 [&_[data-slot=kbd]]:dark:text-zinc-100 [&_[data-slot=kbd]]:dark:ring-zinc-700",
209209 className,
210210 )}
211211 data-slot="command-footer"
apps/web/src/components/ui/sidebar.tsx+10-10
......@@ -676,7 +676,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
676676function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
677677 return (
678678 <Separator
679 className={cn("mx-2 w-auto bg-sidebar-border", className)}
679 className={cn("mx-2 w-auto! bg-sidebar-border", className)}
680680 data-sidebar="separator"
681681 data-slot="sidebar-separator"
682682 {...props}
......@@ -732,7 +732,7 @@ function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentP
732732function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
733733 const defaultProps = {
734734 className: cn(
735 "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
735 "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
736736 // Increases the hit area of the button on mobile.
737737 "after:-inset-2 after:absolute md:after:hidden",
738738 "group-data-[collapsible=icon]:hidden",
......@@ -783,7 +783,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
783783}
784784
785785const sidebarMenuButtonVariants = cva(
786 "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
786 "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
787787 {
788788 defaultVariants: {
789789 size: "default",
......@@ -796,9 +796,9 @@ const sidebarMenuButtonVariants = cva(
796796 sm: "h-7 text-xs",
797797 },
798798 variant: {
799 default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
799 default: "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
800800 outline:
801 "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
801 "bg-sidebar-control-surface ring-1 ring-sidebar-border hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
802802 },
803803 },
804804 },
......@@ -867,7 +867,7 @@ function SidebarMenuAction({
867867}) {
868868 const defaultProps = {
869869 className: cn(
870 "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
870 "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
871871 // Increases the hit area of the button on mobile.
872872 "after:-inset-2 after:absolute md:after:hidden",
873873 "peer-data-[size=sm]/menu-button:top-1",
......@@ -875,7 +875,7 @@ function SidebarMenuAction({
875875 "peer-data-[size=lg]/menu-button:top-2.5",
876876 "group-data-[collapsible=icon]:hidden",
877877 showOnHover &&
878 "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
878 "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0",
879879 className,
880880 ),
881881 "data-sidebar": "menu-action",
......@@ -894,7 +894,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">)
894894 <div
895895 className={cn(
896896 "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-lg px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
897 "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
897 "peer-hover/menu-button:text-sidebar-foreground peer-data-[active=true]/menu-button:text-sidebar-foreground",
898898 "peer-data-[size=sm]/menu-button:top-1",
899899 "peer-data-[size=default]/menu-button:top-1.5",
900900 "peer-data-[size=lg]/menu-button:top-2.5",
......@@ -979,8 +979,8 @@ function SidebarMenuSubButton({
979979}) {
980980 const defaultProps = {
981981 className: cn(
982 "-translate-x-px flex h-7 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
983 "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
982 "-translate-x-px flex h-7 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground",
983 "data-[active=true]:bg-sidebar-row-selected data-[active=true]:text-sidebar-foreground",
984984 size === "sm" && "text-xs",
985985 size === "md" && "text-sm",
986986 "group-data-[collapsible=icon]:hidden",
apps/web/src/index.css+82-62
......@@ -107,6 +107,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
107107}
108108
109109@theme inline {
110 --color-zinc-25: oklch(99.2% 0 0);
110111 --animate-skeleton: skeleton 2s -1s infinite linear;
111112 /* Duty-cycled indicator animations: long holds with stepped ramps, so the
112113 compositor updates discrete frames instead of every vsync. */
......@@ -143,6 +144,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
143144 --color-card: var(--card);
144145 --color-foreground: var(--foreground);
145146 --color-background: var(--background);
147 --color-surface-raised: var(--surface-raised);
148 --color-sidebar: var(--sidebar);
149 --color-sidebar-foreground: var(--sidebar-foreground);
150 --color-sidebar-muted-foreground: var(--sidebar-muted-foreground);
151 --color-sidebar-control-surface: var(--sidebar-control-surface);
152 --color-sidebar-row-hover: var(--sidebar-row-hover);
153 --color-sidebar-row-active: var(--sidebar-row-active);
154 --color-sidebar-row-selected: var(--sidebar-row-selected);
155 --color-sidebar-border: var(--sidebar-border);
146156 --radius-sm: calc(var(--radius) - 4px);
147157 --radius-md: calc(var(--radius) - 2px);
148158 --radius-lg: var(--radius);
......@@ -313,7 +323,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
313323 }
314324
315325 .chat-composer-glass {
316 background: color-mix(in srgb, var(--card) 20%, transparent);
326 background: var(--surface-raised);
317327 }
318328
319329 /* Mix from --background (not --card): the lower chrome is a full-width
......@@ -324,6 +334,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
324334 background: color-mix(in srgb, var(--background) 20%, transparent);
325335 }
326336
337 /* The translucent lower chrome lets the composer's shadow taper naturally
338 into the message canvas. Above the terminal drawer that same shadow reads
339 as a dirty light-to-black gradient, so end the workspace on an opaque
340 surface and let the drawer's border define the boundary instead. */
341 .chat-composer-lower-chrome[data-terminal-open="true"] {
342 background: var(--background);
343 }
344
327345 .chat-composer-glass {
328346 box-shadow:
329347 0 18px 48px -20px rgb(0 0 0 / 28%),
......@@ -336,17 +354,19 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
336354 }
337355
338356 .dark .chat-composer-glass {
339 background: color-mix(in srgb, var(--card) 45%, transparent);
357 background: var(--surface-raised);
340358 }
341359
342360 .dark .chat-composer-lower-chrome {
343361 background: color-mix(in srgb, var(--background) 45%, transparent);
344362 }
345363
364 .dark .chat-composer-lower-chrome[data-terminal-open="true"] {
365 background: var(--background);
366 }
367
346368 .dark .chat-composer-glass {
347 box-shadow:
348 0 18px 48px -20px rgb(0 0 0 / 60%),
349 0 4px 14px -7px rgb(0 0 0 / 40%);
369 box-shadow: none;
350370 }
351371
352372 .chat-composer-lower-chrome {
......@@ -410,24 +430,25 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
410430:root {
411431 color-scheme: light;
412432 --radius: 0.625rem;
413 --background: var(--color-white);
433 --background: var(--color-zinc-25);
414434 --app-chrome-background: var(--background);
415 --foreground: var(--color-neutral-800);
435 --surface-raised: color-mix(in srgb, var(--card) 20%, transparent);
436 --foreground: var(--color-zinc-800);
416437 --card: var(--color-white);
417 --card-foreground: var(--color-neutral-800);
438 --card-foreground: var(--color-zinc-800);
418439 --popover: var(--color-white);
419 --popover-foreground: var(--color-neutral-800);
440 --popover-foreground: var(--color-zinc-800);
420441 --primary: oklch(0.488 0.217 264);
421442 --primary-foreground: var(--color-white);
422 --secondary: --alpha(var(--color-black) / 4%);
423 --secondary-foreground: var(--color-neutral-800);
424 --muted: --alpha(var(--color-black) / 4%);
425 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
426 --accent: --alpha(var(--color-black) / 4%);
427 --accent-foreground: var(--color-neutral-800);
443 --secondary: var(--color-zinc-50);
444 --secondary-foreground: var(--color-zinc-800);
445 --muted: var(--color-zinc-50);
446 --muted-foreground: var(--color-zinc-500);
447 --accent: var(--color-zinc-100);
448 --accent-foreground: var(--color-zinc-900);
428449 --destructive: var(--color-red-500);
429 --border: --alpha(var(--color-black) / 8%);
430 --input: --alpha(var(--color-black) / 10%);
450 --border: var(--color-zinc-200);
451 --input: var(--color-zinc-300);
431452 --ring: oklch(0.488 0.217 264);
432453 --destructive-foreground: var(--color-red-700);
433454 --info: var(--color-blue-500);
......@@ -436,24 +457,35 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
436457 --success-foreground: var(--color-emerald-700);
437458 --warning: var(--color-amber-500);
438459 --warning-foreground: var(--color-amber-700);
460 /* Fallbacks for standalone sidebar primitives. App sidebars override these
461 roles on their own root so the surface hierarchy stays out of page content. */
462 --sidebar: var(--card);
463 --sidebar-foreground: var(--foreground);
464 --sidebar-muted-foreground: var(--muted-foreground);
465 --sidebar-control-surface: var(--accent);
466 --sidebar-row-hover: var(--accent);
467 --sidebar-row-active: var(--accent);
468 --sidebar-row-selected: var(--muted);
469 --sidebar-border: var(--border);
439470
440471 @variant dark {
441472 color-scheme: dark;
442 --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));
473 --background: var(--color-zinc-950);
443474 --app-chrome-background: var(--background);
444 --foreground: var(--color-neutral-100);
445 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
446 --card-foreground: var(--color-neutral-100);
447 --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));
448 --popover-foreground: var(--color-neutral-100);
475 --surface-raised: color-mix(in srgb, var(--card) 45%, transparent);
476 --foreground: var(--color-zinc-100);
477 --card: var(--color-zinc-900);
478 --card-foreground: var(--color-zinc-100);
479 --popover: var(--color-zinc-900);
480 --popover-foreground: var(--color-zinc-100);
449481 --primary: oklch(0.588 0.217 264);
450482 --primary-foreground: var(--color-white);
451 --secondary: --alpha(var(--color-white) / 4%);
452 --secondary-foreground: var(--color-neutral-100);
453 --muted: --alpha(var(--color-white) / 4%);
454 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
455 --accent: --alpha(var(--color-white) / 4%);
456 --accent-foreground: var(--color-neutral-100);
483 --secondary: var(--color-zinc-900);
484 --secondary-foreground: var(--color-zinc-100);
485 --muted: var(--color-zinc-900);
486 --muted-foreground: var(--color-zinc-400);
487 --accent: var(--color-zinc-900);
488 --accent-foreground: var(--color-zinc-100);
457489 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
458490 --border: --alpha(var(--color-white) / 6%);
459491 --input: --alpha(var(--color-white) / 8%);
......@@ -468,39 +500,27 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
468500 }
469501}
470502
471/* Give the navigation rail its own cool graphite surface instead of borrowing
472 the workspace card color. The scoped semantic tokens keep sidebar v2's
473 controls in the same palette without changing v1 or the settings nav. */
474.app-sidebar {
475 --background: oklch(0.965 0.009 265);
476 --foreground: oklch(0.27 0.025 265);
477 --card: oklch(0.945 0.014 265);
478 --card-foreground: var(--foreground);
479 --accent: oklch(0.875 0.008 265);
480 --accent-foreground: oklch(0.24 0.012 265);
481 --muted: oklch(0.9 0.018 265);
482 --muted-foreground: oklch(0.49 0.03 265);
483 --border: oklch(0.72 0.025 265 / 28%);
484 --input: oklch(0.62 0.03 265 / 28%);
485 --sidebar-stage-fade: var(--card);
486 background-color: var(--card);
487}
488
489.dark .app-sidebar {
490 --background: #000;
491 --foreground: #f1f3f7;
492 --card: #000;
493 --card-foreground: var(--foreground);
494 --accent: #191a1d;
495 --accent-foreground: #f7f9ff;
496 --muted: #0a0a0a;
497 --muted-foreground: #a3a3a3;
498 --border: rgb(255 255 255 / 14%);
499 --input: rgb(255 255 255 / 18%);
500 /* The stage-channel header art must ramp to THIS panel's surface, not the
501 global chrome background, or the fade shows a seam (same rule as the
502 light palette above). */
503 --sidebar-stage-fade: var(--card);
503[data-app-sidebar] {
504 --sidebar: var(--color-zinc-50);
505 --sidebar-foreground: var(--color-zinc-800);
506 --sidebar-muted-foreground: var(--color-zinc-500);
507 --sidebar-control-surface: var(--color-zinc-100);
508 --sidebar-row-hover: var(--color-zinc-25);
509 --sidebar-row-active: var(--color-white);
510 --sidebar-row-selected: var(--color-white);
511 --sidebar-border: var(--color-zinc-200);
512 --sidebar-stage-fade: var(--sidebar);
513}
514
515.dark [data-app-sidebar] {
516 --sidebar: var(--color-zinc-950);
517 --sidebar-foreground: var(--color-zinc-100);
518 --sidebar-muted-foreground: var(--color-zinc-400);
519 --sidebar-control-surface: var(--color-zinc-800);
520 --sidebar-row-hover: var(--color-zinc-900);
521 --sidebar-row-active: var(--color-zinc-900);
522 --sidebar-row-selected: var(--color-zinc-900);
523 --sidebar-border: var(--color-zinc-800);
504524}
505525
506526body {