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 {...@@ -4,6 +4,8 @@ import type {
4 SidebarThreadSortOrder,4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";6import type { MenuAction } from "@react-native-menu/menu";
7import { useAtomValue } from "@effect/atom-react";
8import { AsyncResult } from "effect/unstable/reactivity";
7import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";9import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
8import { useCallback, useMemo, useRef } from "react";10import { useCallback, useMemo, useRef } from "react";
9import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";11import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native";
...@@ -14,6 +16,7 @@ import { ControlPillMenu } from "../../components/ControlPill";...@@ -14,6 +16,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
14import { SymbolView } from "../../components/AppSymbol";16import { SymbolView } from "../../components/AppSymbol";
15import { T3Wordmark } from "../../components/T3Wordmark";17import { T3Wordmark } from "../../components/T3Wordmark";
16import { useThemeColor } from "../../lib/useThemeColor";18import { useThemeColor } from "../../lib/useThemeColor";
19import { mobilePreferencesAtom } from "../../state/preferences";
17import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";20import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
18import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";21import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
19import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";22import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
...@@ -21,6 +24,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList";...@@ -21,6 +24,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList";
21import {24import {
22 buildHomeListFilterMenu,25 buildHomeListFilterMenu,
23 type HomeListFilterMenuEnvironment,26 type HomeListFilterMenuEnvironment,
27 type HomeListFilterMenuProject,
24} from "./home-list-filter-menu";28} from "./home-list-filter-menu";
25import {29import {
26 hasCustomHomeListOptions,30 hasCustomHomeListOptions,
...@@ -33,13 +37,16 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;...@@ -33,13 +37,16 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
3337
34export function HomeHeader(props: {38export function HomeHeader(props: {
35 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;39 readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
40 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
36 readonly searchQuery: string;41 readonly searchQuery: string;
37 readonly selectedEnvironmentId: EnvironmentId | null;42 readonly selectedEnvironmentId: EnvironmentId | null;
43 readonly selectedProjectKey: string | null;
38 readonly projectSortOrder: HomeProjectSortOrder;44 readonly projectSortOrder: HomeProjectSortOrder;
39 readonly threadSortOrder: SidebarThreadSortOrder;45 readonly threadSortOrder: SidebarThreadSortOrder;
40 readonly projectGroupingMode: SidebarProjectGroupingMode;46 readonly projectGroupingMode: SidebarProjectGroupingMode;
41 readonly onSearchQueryChange: (query: string) => void;47 readonly onSearchQueryChange: (query: string) => void;
42 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;48 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
49 readonly onProjectChange: (projectKey: string | null) => void;
43 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;50 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
44 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;51 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
45 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;52 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
...@@ -59,11 +66,24 @@ function checkedMenuState(checked: boolean) {...@@ -59,11 +66,24 @@ function checkedMenuState(checked: boolean) {
59 return checked ? ("on" as const) : undefined;66 return checked ? ("on" as const) : undefined;
60}67}
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
62function AndroidHomeHeader(props: HomeHeaderProps) {79function AndroidHomeHeader(props: HomeHeaderProps) {
63 const insets = useSafeAreaInsets();80 const insets = useSafeAreaInsets();
64 const iconColor = useThemeColor("--color-icon");81 const iconColor = useThemeColor("--color-icon");
65 const mutedColor = useThemeColor("--color-foreground-muted");82 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);
67 const menuActions = useMemo<MenuAction[]>(87 const menuActions = useMemo<MenuAction[]>(
68 () => [88 () => [
69 {89 {
...@@ -82,40 +102,67 @@ function AndroidHomeHeader(props: HomeHeaderProps) {...@@ -82,40 +102,67 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
82 })),102 })),
83 ],103 ],
84 },104 },
85 {105 ...(props.projects.length === 0
86 id: "project-sort",106 ? []
87 title: "Sort projects",107 : ([
88 subactions: PROJECT_SORT_OPTIONS.map((option) => ({108 {
89 id: `project-sort:${option.value}`,109 id: "project",
90 title: option.label,110 title: "Project",
91 state: checkedMenuState(props.projectSortOrder === option.value),111 subactions: [
92 })),112 {
93 },113 id: "project:all",
94 {114 title: "All projects",
95 id: "thread-sort",115 state: checkedMenuState(props.selectedProjectKey === null),
96 title: "Sort threads",116 },
97 subactions: THREAD_SORT_OPTIONS.map((option) => ({117 ...props.projects.map((project) => ({
98 id: `thread-sort:${option.value}`,118 id: `project:${project.key}`,
99 title: option.label,119 title: project.label,
100 state: checkedMenuState(props.threadSortOrder === option.value),120 state: checkedMenuState(props.selectedProjectKey === project.key),
101 })),121 })),
102 },122 ],
103 {123 },
104 id: "project-grouping",124 ] satisfies MenuAction[])),
105 title: "Group projects",125 ...(threadListV2Enabled
106 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({126 ? []
107 id: `project-grouping:${option.value}`,127 : ([
108 title: option.label,128 {
109 state: checkedMenuState(props.projectGroupingMode === option.value),129 id: "project-sort",
110 })),130 title: "Sort projects",
111 },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[])),
112 ],156 ],
113 [157 [
114 props.environments,158 props.environments,
115 props.projectGroupingMode,159 props.projectGroupingMode,
116 props.projectSortOrder,160 props.projectSortOrder,
161 props.projects,
117 props.selectedEnvironmentId,162 props.selectedEnvironmentId,
163 props.selectedProjectKey,
118 props.threadSortOrder,164 props.threadSortOrder,
165 threadListV2Enabled,
119 ],166 ],
120 );167 );
121 const handleMenuAction = useCallback(168 const handleMenuAction = useCallback(
...@@ -137,6 +184,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) {...@@ -137,6 +184,19 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
137 return;184 return;
138 }185 }
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
140 const projectSort = PROJECT_SORT_OPTIONS.find(200 const projectSort = PROJECT_SORT_OPTIONS.find(
141 (option) => id === `project-sort:${option.value}`,201 (option) => id === `project-sort:${option.value}`,
142 );202 );
...@@ -255,17 +315,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) {...@@ -255,17 +315,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
255function IosHomeHeader(props: HomeHeaderProps) {315function IosHomeHeader(props: HomeHeaderProps) {
256 const searchBarRef = useRef<SearchBarCommands>(null);316 const searchBarRef = useRef<SearchBarCommands>(null);
257 const iconColor = useThemeColor("--color-icon");317 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);
259 const focusSearch = useCallback(() => {322 const focusSearch = useCallback(() => {
260 searchBarRef.current?.focus();323 searchBarRef.current?.focus();
261 return searchBarRef.current !== null;324 return searchBarRef.current !== null;
262 }, []);325 }, []);
263 useHardwareKeyboardCommand("focusSearch", focusSearch);326 useHardwareKeyboardCommand("focusSearch", focusSearch);
264 const filterMenu = buildHomeListFilterMenu(props);327 const filterMenu = buildHomeListFilterMenu({
328 ...props,
329 listOrganization: !threadListV2Enabled,
330 });
265331
266 return (332 return (
267 <>333 <>
268 <NativeStackScreenOptions334 <NativeStackScreenOptions
335 optionsVersion={filterMenu.items}
269 options={{336 options={{
270 // Static header config (glass, title, fonts) lives in Stack.tsx337 // Static header config (glass, title, fonts) lives in Stack.tsx
271 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.338 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
...@@ -366,6 +433,28 @@ function IosHomeHeader(props: HomeHeaderProps) {...@@ -366,6 +433,28 @@ function IosHomeHeader(props: HomeHeaderProps) {
366 ))}433 ))}
367 </NativeHeaderToolbar.Menu>434 </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
369 <NativeHeaderToolbar.Menu title="Sort projects">458 <NativeHeaderToolbar.Menu title="Sort projects">
370 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>459 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
371 {PROJECT_SORT_OPTIONS.map((option) => (460 {PROJECT_SORT_OPTIONS.map((option) => (
apps/mobile/src/features/home/HomeRouteScreen.tsx+29-1
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import * as Arr from "effect/Array";1import * as Arr from "effect/Array";
2import * as Order from "effect/Order";2import * as Order from "effect/Order";
3import { useNavigation } from "@react-navigation/native";3import { useNavigation } from "@react-navigation/native";
4import { useMemo, useState } from "react";4import { useEffect, useMemo, useState } from "react";
55
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
7import { scopedProjectKey } from "../../lib/scopedEntities";
7import { useProjects, useThreadShells } from "../../state/entities";8import { useProjects, useThreadShells } from "../../state/entities";
8import { usePendingNewTasks } from "../../state/use-pending-new-tasks";9import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
9import { useWorkspaceState } from "../../state/workspace";10import { useWorkspaceState } from "../../state/workspace";
...@@ -58,6 +59,28 @@ export function HomeRouteScreen() {...@@ -58,6 +59,28 @@ export function HomeRouteScreen() {
58 setThreadSortOrder,59 setThreadSortOrder,
59 } = useHomeListOptions(availableEnvironmentIds);60 } = useHomeListOptions(availableEnvironmentIds);
60 const selectedEnvironmentId = listOptions.selectedEnvironmentId;61 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
62 // In split layouts the persistent sidebar IS the thread list — Home becomes85 // In split layouts the persistent sidebar IS the thread list — Home becomes
63 // an empty detail pane so selecting a thread never transitions layouts.86 // an empty detail pane so selecting a thread never transitions layouts.
...@@ -90,12 +113,15 @@ export function HomeRouteScreen() {...@@ -90,12 +113,15 @@ export function HomeRouteScreen() {
90 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />113 <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
91 <HomeHeader114 <HomeHeader
92 environments={environments}115 environments={environments}
116 projects={projectFilterOptions}
93 searchQuery={searchQuery}117 searchQuery={searchQuery}
94 selectedEnvironmentId={selectedEnvironmentId}118 selectedEnvironmentId={selectedEnvironmentId}
119 selectedProjectKey={selectedProjectKey}
95 projectSortOrder={listOptions.projectSortOrder}120 projectSortOrder={listOptions.projectSortOrder}
96 threadSortOrder={listOptions.threadSortOrder}121 threadSortOrder={listOptions.threadSortOrder}
97 projectGroupingMode={listOptions.projectGroupingMode}122 projectGroupingMode={listOptions.projectGroupingMode}
98 onEnvironmentChange={setSelectedEnvironmentId}123 onEnvironmentChange={setSelectedEnvironmentId}
124 onProjectChange={setSelectedProjectKey}
99 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}125 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
100 onProjectGroupingModeChange={setProjectGroupingMode}126 onProjectGroupingModeChange={setProjectGroupingMode}
101 onProjectSortOrderChange={setProjectSortOrder}127 onProjectSortOrderChange={setProjectSortOrder}
...@@ -115,6 +141,7 @@ export function HomeRouteScreen() {...@@ -115,6 +141,7 @@ export function HomeRouteScreen() {
115 onSettleThread={settleThread}141 onSettleThread={settleThread}
116 onUnsettleThread={unsettleThread}142 onUnsettleThread={unsettleThread}
117 onEnvironmentChange={setSelectedEnvironmentId}143 onEnvironmentChange={setSelectedEnvironmentId}
144 onProjectChange={setSelectedProjectKey}
118 onOpenEnvironments={() =>145 onOpenEnvironments={() =>
119 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })146 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
120 }147 }
...@@ -151,6 +178,7 @@ export function HomeRouteScreen() {...@@ -151,6 +178,7 @@ export function HomeRouteScreen() {
151 savedConnectionsById={savedConnectionsById}178 savedConnectionsById={savedConnectionsById}
152 searchQuery={searchQuery}179 searchQuery={searchQuery}
153 selectedEnvironmentId={selectedEnvironmentId}180 selectedEnvironmentId={selectedEnvironmentId}
181 selectedProjectKey={selectedProjectKey}
154 threads={threads}182 threads={threads}
155 threadSortOrder={listOptions.threadSortOrder}183 threadSortOrder={listOptions.threadSortOrder}
156 />184 />
apps/mobile/src/features/home/HomeScreen.tsx+77-98
...@@ -38,7 +38,12 @@ import {...@@ -38,7 +38,12 @@ import {
38 ThreadListShowMoreRow,38 ThreadListShowMoreRow,
39} from "../threads/thread-list-items";39} from "../threads/thread-list-items";
40import { ThreadListV2Row } from "../threads/thread-list-v2-items";40import { 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";
42import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";47import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
43import {48import {
44 buildHomeListLayout,49 buildHomeListLayout,
...@@ -65,11 +70,13 @@ interface HomeScreenProps {...@@ -65,11 +70,13 @@ interface HomeScreenProps {
65 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;70 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
66 readonly searchQuery: string;71 readonly searchQuery: string;
67 readonly selectedEnvironmentId: EnvironmentId | null;72 readonly selectedEnvironmentId: EnvironmentId | null;
73 readonly selectedProjectKey: string | null;
68 readonly projectSortOrder: HomeProjectSortOrder;74 readonly projectSortOrder: HomeProjectSortOrder;
69 readonly threadSortOrder: SidebarThreadSortOrder;75 readonly threadSortOrder: SidebarThreadSortOrder;
70 readonly projectGroupingMode: SidebarProjectGroupingMode;76 readonly projectGroupingMode: SidebarProjectGroupingMode;
71 readonly onSearchQueryChange: (query: string) => void;77 readonly onSearchQueryChange: (query: string) => void;
72 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;78 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
79 readonly onProjectChange: (projectKey: string | null) => void;
73 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;80 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
74 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;81 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
75 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;82 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
...@@ -91,10 +98,6 @@ interface HomeScreenProps {...@@ -91,10 +98,6 @@ interface HomeScreenProps {
91/* ─── Layout constants ───────────────────────────────────────────────── */98/* ─── Layout constants ───────────────────────────────────────────────── */
9299
93const ESTIMATED_THREAD_ROW_HEIGHT = 72;100const 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;
98/**101/**
99 * Top spacing between the list and the Android custom header. The Android102 * Top spacing between the list and the Android custom header. The Android
100 * header (AndroidHomeHeader) is rendered in-flow above this screen and103 * header (AndroidHomeHeader) is rendered in-flow above this screen and
...@@ -169,80 +172,6 @@ function HomeTopContentSpacer() {...@@ -169,80 +172,6 @@ function HomeTopContentSpacer() {
169 return <View className="h-4" />;172 return <View className="h-4" />;
170}173}
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
246/* ─── Main screen ────────────────────────────────────────────────────── */175/* ─── Main screen ────────────────────────────────────────────────────── */
247176
248export function HomeScreen(props: HomeScreenProps) {177export function HomeScreen(props: HomeScreenProps) {
...@@ -314,12 +243,51 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -314,12 +243,51 @@ export function HomeScreen(props: HomeScreenProps) {
314 onScrollBeginDrag: handleScrollBeginDrag,243 onScrollBeginDrag: handleScrollBeginDrag,
315 });244 });
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
317 const projectGroups = useMemo(285 const projectGroups = useMemo(
318 () =>286 () =>
319 buildHomeThreadGroups({287 buildHomeThreadGroups({
320 projects: props.projects,288 projects: scopedProjects,
321 threads: props.threads,289 threads: scopedThreads,
322 pendingTasks: props.pendingTasks,290 pendingTasks: scopedPendingTasks,
323 environmentId: props.selectedEnvironmentId,291 environmentId: props.selectedEnvironmentId,
324 searchQuery: props.searchQuery,292 searchQuery: props.searchQuery,
325 projectSortOrder: props.projectSortOrder,293 projectSortOrder: props.projectSortOrder,
...@@ -327,14 +295,14 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -327,14 +295,14 @@ export function HomeScreen(props: HomeScreenProps) {
327 projectGroupingMode: props.projectGroupingMode,295 projectGroupingMode: props.projectGroupingMode,
328 }),296 }),
329 [297 [
330 props.pendingTasks,
331 props.projectGroupingMode,298 props.projectGroupingMode,
332 props.projects,
333 props.projectSortOrder,299 props.projectSortOrder,
334 props.searchQuery,300 props.searchQuery,
335 props.selectedEnvironmentId,301 props.selectedEnvironmentId,
336 props.threadSortOrder,302 props.threadSortOrder,
337 props.threads,303 scopedPendingTasks,
304 scopedProjects,
305 scopedThreads,
338 ],306 ],
339 );307 );
340308
...@@ -365,7 +333,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -365,7 +333,8 @@ export function HomeScreen(props: HomeScreenProps) {
365 return map;333 return map;
366 }, [props.projects]);334 }, [props.projects]);
367335
368 const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState<string | null>(null);336 const v2ProjectScopeKey = props.selectedProjectKey;
337 const setV2ProjectScopeKey = props.onProjectChange;
369 const v2ScopeProjects = useMemo(338 const v2ScopeProjects = useMemo(
370 () =>339 () =>
371 props.selectedEnvironmentId === null340 props.selectedEnvironmentId === null
...@@ -382,12 +351,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -382,12 +351,6 @@ export function HomeScreen(props: HomeScreenProps) {
382 ) ?? null),351 ) ?? null),
383 [v2ProjectScopeKey, v2ScopeProjects],352 [v2ProjectScopeKey, v2ScopeProjects],
384 );353 );
385 useEffect(() => {
386 if (v2ProjectScopeKey !== null && v2ScopedProject === null) {
387 setV2ProjectScopeKey(null);
388 }
389 }, [v2ProjectScopeKey, v2ScopedProject]);
390
391 // Thread List v2 (beta): one flat list in creation order, no grouping.354 // Thread List v2 (beta): one flat list in creation order, no grouping.
392 // Settled threads collapse into a recency tail below the card block.355 // Settled threads collapse into a recency tail below the card block.
393 // Settled threads stay in the live shell stream (settled ≠ archived), so356 // Settled threads stay in the live shell stream (settled ≠ archived), so
...@@ -442,6 +405,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -442,6 +405,10 @@ export function HomeScreen(props: HomeScreenProps) {
442 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));405 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
443 useEffect(() => {406 useEffect(() => {
444 if (!threadListV2Enabled) return;407 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));
445 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);412 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
446 return () => clearInterval(id);413 return () => clearInterval(id);
447 }, [threadListV2Enabled]);414 }, [threadListV2Enabled]);
...@@ -509,6 +476,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -509,6 +476,11 @@ export function HomeScreen(props: HomeScreenProps) {
509 (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId),476 (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId),
510 )?.driver ?? null477 )?.driver ?? null
511 }478 }
479 environmentLabel={
480 Object.keys(props.savedConnectionsById).length > 1
481 ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null)
482 : null
483 }
512 onSelectThread={props.onSelectThread}484 onSelectThread={props.onSelectThread}
513 onDeleteThread={handleDeleteThread}485 onDeleteThread={handleDeleteThread}
514 onArchiveThread={props.onArchiveThread}486 onArchiveThread={props.onArchiveThread}
...@@ -535,6 +507,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -535,6 +507,7 @@ export function HomeScreen(props: HomeScreenProps) {
535 projectCwdByKey,507 projectCwdByKey,
536 props.onArchiveThread,508 props.onArchiveThread,
537 props.onSelectThread,509 props.onSelectThread,
510 props.savedConnectionsById,
538 serverConfigs,511 serverConfigs,
539 settlementEnvironmentIds,512 settlementEnvironmentIds,
540 ],513 ],
...@@ -731,14 +704,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -731,14 +704,11 @@ export function HomeScreen(props: HomeScreenProps) {
731 pendingTask.creation.projectId === v2ScopedProject.id)) &&704 pendingTask.creation.projectId === v2ScopedProject.id)) &&
732 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),705 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
733 );706 );
707 // Project scoping lives in the header filter menu (no inline chip row on
708 // mobile — the menu is the one filter surface).
734 const v2ListHeader = (709 const v2ListHeader = (
735 <>710 <>
736 {listHeader}711 {listHeader}
737 <ThreadListV2ProjectScope
738 projects={v2ScopeProjects}
739 selectedKey={v2ProjectScopeKey}
740 onChange={setV2ProjectScopeKey}
741 />
742 {v2PendingTasks.map((pendingTask, index) => (712 {v2PendingTasks.map((pendingTask, index) => (
743 <PendingTaskListRow713 <PendingTaskListRow
744 key={pendingTask.message.messageId}714 key={pendingTask.message.messageId}
...@@ -758,6 +728,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -758,6 +728,11 @@ export function HomeScreen(props: HomeScreenProps) {
758 const listEmpty = !hasResults ? (728 const listEmpty = !hasResults ? (
759 hasSearchQuery ? (729 hasSearchQuery ? (
760 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />730 <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 />
761 ) : selectedEnvironmentLabel ? (736 ) : selectedEnvironmentLabel ? (
762 <EmptyState737 <EmptyState
763 title={`No threads in ${selectedEnvironmentLabel}`}738 title={`No threads in ${selectedEnvironmentLabel}`}
...@@ -792,7 +767,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -792,7 +767,11 @@ export function HomeScreen(props: HomeScreenProps) {
792 data={threadListV2Items}767 data={threadListV2Items}
793 renderItem={renderV2Item}768 renderItem={renderV2Item}
794 keyExtractor={v2KeyExtractor}769 keyExtractor={v2KeyExtractor}
795 extraData={{ projectByKey, serverConfigs }}770 extraData={{
771 projectByKey,
772 serverConfigs,
773 savedConnectionsById: props.savedConnectionsById,
774 }}
796 ListHeaderComponent={v2ListHeader}775 ListHeaderComponent={v2ListHeader}
797 ListFooterComponent={776 ListFooterComponent={
798 threadListV2Layout.hiddenSettledCount > 0 ? (777 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 {...@@ -16,6 +16,11 @@ export interface HomeListFilterMenuEnvironment {
16 readonly label: string;16 readonly label: string;
17}17}
1818
19export interface HomeListFilterMenuProject {
20 readonly key: string;
21 readonly label: string;
22}
23
19type HomeListFilterMenuAction = {24type HomeListFilterMenuAction = {
20 readonly type: "action";25 readonly type: "action";
21 readonly title: string;26 readonly title: string;
...@@ -37,15 +42,22 @@ export interface HomeListFilterMenu {...@@ -37,15 +42,22 @@ export interface HomeListFilterMenu {
3742
38export function buildHomeListFilterMenu(props: {43export function buildHomeListFilterMenu(props: {
39 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;44 readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>;
45 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
40 readonly selectedEnvironmentId: EnvironmentId | null;46 readonly selectedEnvironmentId: EnvironmentId | null;
47 readonly selectedProjectKey: string | null;
41 readonly projectSortOrder: HomeProjectSortOrder;48 readonly projectSortOrder: HomeProjectSortOrder;
42 readonly threadSortOrder: SidebarThreadSortOrder;49 readonly threadSortOrder: SidebarThreadSortOrder;
43 readonly projectGroupingMode: SidebarProjectGroupingMode;50 readonly projectGroupingMode: SidebarProjectGroupingMode;
44 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;51 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
52 readonly onProjectChange: (projectKey: string | null) => void;
45 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;53 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
46 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;54 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
47 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;55 readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
48 readonly onOpenSettings?: () => void;56 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;
49}): HomeListFilterMenu {61}): HomeListFilterMenu {
50 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];62 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
5163
...@@ -57,61 +69,86 @@ export function buildHomeListFilterMenu(props: {...@@ -57,61 +69,86 @@ export function buildHomeListFilterMenu(props: {
57 });69 });
58 }70 }
5971
60 items.push(72 items.push({
61 {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({
62 type: "submenu",97 type: "submenu",
63 title: "Environment",98 title: "Project",
64 items: [99 items: [
65 {100 {
66 type: "action",101 type: "action",
67 title: "All environments",102 title: "All projects",
68 subtitle: "Show threads from every environment",103 subtitle: "Show threads from every project",
69 state: props.selectedEnvironmentId === null ? "on" : "off",104 state: props.selectedProjectKey === null ? "on" : "off",
70 onPress: () => props.onEnvironmentChange(null),105 onPress: () => props.onProjectChange(null),
71 },106 },
72 ...props.environments.map((environment) => ({107 ...props.projects.map((project) => ({
73 type: "action" as const,108 type: "action" as const,
74 title: environment.label,109 title: project.label,
75 state:110 state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const),
76 props.selectedEnvironmentId === environment.environmentId111 onPress: () => props.onProjectChange(project.key),
77 ? ("on" as const)
78 : ("off" as const),
79 onPress: () => props.onEnvironmentChange(environment.environmentId),
80 })),112 })),
81 ],113 ],
82 },114 });
83 {115 }
84 type: "submenu",116
85 title: "Sort projects",117 if (props.listOrganization !== false) {
86 items: PROJECT_SORT_OPTIONS.map((option) => ({118 items.push(
87 type: "action",119 {
88 title: option.label,120 type: "submenu",
89 state: props.projectSortOrder === option.value ? "on" : "off",121 title: "Sort projects",
90 onPress: () => props.onProjectSortOrderChange(option.value),122 items: PROJECT_SORT_OPTIONS.map((option) => ({
91 })),123 type: "action",
92 },124 title: option.label,
93 {125 state: props.projectSortOrder === option.value ? "on" : "off",
94 type: "submenu",126 onPress: () => props.onProjectSortOrderChange(option.value),
95 title: "Sort threads",127 })),
96 items: THREAD_SORT_OPTIONS.map((option) => ({128 },
97 type: "action",129 {
98 title: option.label,130 type: "submenu",
99 state: props.threadSortOrder === option.value ? "on" : "off",131 title: "Sort threads",
100 onPress: () => props.onThreadSortOrderChange(option.value),132 items: THREAD_SORT_OPTIONS.map((option) => ({
101 })),133 type: "action",
102 },134 title: option.label,
103 {135 state: props.threadSortOrder === option.value ? "on" : "off",
104 type: "submenu",136 onPress: () => props.onThreadSortOrderChange(option.value),
105 title: "Group projects",137 })),
106 items: PROJECT_GROUPING_OPTIONS.map((option) => ({138 },
107 type: "action",139 {
108 title: option.label,140 type: "submenu",
109 subtitle: option.subtitle,141 title: "Group projects",
110 state: props.projectGroupingMode === option.value ? "on" : "off",142 items: PROJECT_GROUPING_OPTIONS.map((option) => ({
111 onPress: () => props.onProjectGroupingModeChange(option.value),143 type: "action",
112 })),144 title: option.label,
113 },145 subtitle: option.subtitle,
114 );146 state: props.projectGroupingMode === option.value ? "on" : "off",
147 onPress: () => props.onProjectGroupingModeChange(option.value),
148 })),
149 },
150 );
151 }
115152
116 return {153 return {
117 title: "Thread list options",154 title: "Thread list options",
apps/mobile/src/features/home/home-list-options.test.ts+3
...@@ -27,5 +27,8 @@ describe("home list options", () => {...@@ -27,5 +27,8 @@ describe("home list options", () => {
27 hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),27 hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),
28 ).toBe(true);28 ).toBe(true);
29 expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true);29 expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true);
30 expect(
31 hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }),
32 ).toBe(true);
30 });33 });
31});34});
apps/mobile/src/features/home/home-list-options.ts+4-1
...@@ -93,13 +93,16 @@ export function HomeListOptionsProvider({ children }: PropsWithChildren) {...@@ -93,13 +93,16 @@ export function HomeListOptionsProvider({ children }: PropsWithChildren) {
93 return createElement(HomeListOptionsContext, { value }, children);93 return createElement(HomeListOptionsContext, { value }, children);
94}94}
9595
96export function hasCustomHomeListOptions(options: HomeListOptions): boolean {96export function hasCustomHomeListOptions(
97 options: HomeListOptions & { readonly selectedProjectKey?: string | null },
98): boolean {
97 const defaultProjectSortOrder =99 const defaultProjectSortOrder =
98 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"100 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
99 ? "updated_at"101 ? "updated_at"
100 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;102 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;
101 return (103 return (
102 options.selectedEnvironmentId !== null ||104 options.selectedEnvironmentId !== null ||
105 (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) ||
103 options.projectSortOrder !== defaultProjectSortOrder ||106 options.projectSortOrder !== defaultProjectSortOrder ||
104 options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||107 options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
105 options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE108 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";...@@ -36,6 +36,8 @@ import { AppText as Text } from "../../components/AppText";
36const ACTION_ITEM_WIDTH = 58;36const ACTION_ITEM_WIDTH = 58;
37const ACTION_CIRCLE_SIZE = 36;37const ACTION_CIRCLE_SIZE = 36;
38const ACTION_ICON_SIZE = 15;38const ACTION_ICON_SIZE = 15;
39const COMPACT_ACTION_CIRCLE_SIZE = 28;
40const COMPACT_ACTION_ICON_SIZE = 13;
3941
40export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;42export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;
41export const THREAD_SWIPE_SPRING = {43export const THREAD_SWIPE_SPRING = {
...@@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: {...@@ -163,6 +165,9 @@ export function useSwipeableScrollGate(options?: {
163export function ThreadSwipeable(props: {165export function ThreadSwipeable(props: {
164 readonly backgroundColor: ColorValue;166 readonly backgroundColor: ColorValue;
165 readonly children: (close: () => void) => ReactNode;167 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;
166 readonly containerStyle?: StyleProp<ViewStyle>;171 readonly containerStyle?: StyleProp<ViewStyle>;
167 /** Disables NEW swipe activations (e.g. while the list scrolls). */172 /** Disables NEW swipe activations (e.g. while the list scrolls). */
168 readonly enabled?: boolean;173 readonly enabled?: boolean;
...@@ -258,6 +263,7 @@ export function ThreadSwipeable(props: {...@@ -258,6 +263,7 @@ export function ThreadSwipeable(props: {
258 renderRightActions={(_progress, translation, methods) => (263 renderRightActions={(_progress, translation, methods) => (
259 <ThreadSwipeActions264 <ThreadSwipeActions
260 backgroundColor={props.backgroundColor}265 backgroundColor={props.backgroundColor}
266 compact={props.compactActions === true}
261 fullSwipeAction={props.fullSwipeAction ?? "delete"}267 fullSwipeAction={props.fullSwipeAction ?? "delete"}
262 fullSwipeThreshold={fullSwipeThreshold}268 fullSwipeThreshold={fullSwipeThreshold}
263 onDelete={props.onDelete}269 onDelete={props.onDelete}
...@@ -285,6 +291,7 @@ export function ThreadSwipeable(props: {...@@ -285,6 +291,7 @@ export function ThreadSwipeable(props: {
285function SwipeActionButton(props: {291function SwipeActionButton(props: {
286 readonly accessibilityLabel: string;292 readonly accessibilityLabel: string;
287 readonly backgroundColor: string;293 readonly backgroundColor: string;
294 readonly compact: boolean;
288 readonly entryRange: readonly [number, number];295 readonly entryRange: readonly [number, number];
289 readonly fullSwipeThreshold: number;296 readonly fullSwipeThreshold: number;
290 readonly icon: ComponentProps<typeof SymbolView>["name"];297 readonly icon: ComponentProps<typeof SymbolView>["name"];
...@@ -293,6 +300,8 @@ function SwipeActionButton(props: {...@@ -293,6 +300,8 @@ function SwipeActionButton(props: {
293 readonly stretchesOnFullSwipe: boolean;300 readonly stretchesOnFullSwipe: boolean;
294 readonly translation: SharedValue<number>;301 readonly translation: SharedValue<number>;
295}) {302}) {
303 const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE;
304 const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE;
296 const actionStyle = useAnimatedStyle(() => {305 const actionStyle = useAnimatedStyle(() => {
297 const reveal = Math.max(-props.translation.value, 0);306 const reveal = Math.max(-props.translation.value, 0);
298 const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP);307 const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP);
...@@ -324,7 +333,7 @@ function SwipeActionButton(props: {...@@ -324,7 +333,7 @@ function SwipeActionButton(props: {
324333
325 return {334 return {
326 transform: [{ translateX: -stretch }],335 transform: [{ translateX: -stretch }],
327 width: ACTION_CIRCLE_SIZE + stretch,336 width: circleSize + stretch,
328 };337 };
329 });338 });
330 const iconStyle = useAnimatedStyle(() => {339 const iconStyle = useAnimatedStyle(() => {
...@@ -386,13 +395,13 @@ function SwipeActionButton(props: {...@@ -386,13 +395,13 @@ function SwipeActionButton(props: {
386 width: "100%",395 width: "100%",
387 })}396 })}
388 >397 >
389 <View style={{ height: ACTION_CIRCLE_SIZE, width: ACTION_CIRCLE_SIZE }}>398 <View style={{ height: circleSize, width: circleSize }}>
390 <Animated.View399 <Animated.View
391 style={[400 style={[
392 {401 {
393 backgroundColor: props.backgroundColor,402 backgroundColor: props.backgroundColor,
394 borderRadius: 999,403 borderRadius: 999,
395 height: ACTION_CIRCLE_SIZE,404 height: circleSize,
396 left: 0,405 left: 0,
397 position: "absolute",406 position: "absolute",
398 top: 0,407 top: 0,
...@@ -404,25 +413,25 @@ function SwipeActionButton(props: {...@@ -404,25 +413,25 @@ function SwipeActionButton(props: {
404 style={[413 style={[
405 {414 {
406 alignItems: "center",415 alignItems: "center",
407 height: ACTION_CIRCLE_SIZE,416 height: circleSize,
408 justifyContent: "center",417 justifyContent: "center",
409 left: 0,418 left: 0,
410 position: "absolute",419 position: "absolute",
411 top: 0,420 top: 0,
412 width: ACTION_CIRCLE_SIZE,421 width: circleSize,
413 },422 },
414 iconStyle,423 iconStyle,
415 ]}424 ]}
416 >425 >
417 <SymbolView426 <SymbolView name={props.icon} size={iconSize} tintColor="#ffffff" type="monochrome" />
418 name={props.icon}
419 size={ACTION_ICON_SIZE}
420 tintColor="#ffffff"
421 type="monochrome"
422 />
423 </Animated.View>427 </Animated.View>
424 </View>428 </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 >
426 <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}>435 <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}>
427 {props.label}436 {props.label}
428 </Text>437 </Text>
...@@ -434,6 +443,7 @@ function SwipeActionButton(props: {...@@ -434,6 +443,7 @@ function SwipeActionButton(props: {
434443
435export function ThreadSwipeActions(props: {444export function ThreadSwipeActions(props: {
436 readonly backgroundColor: ColorValue;445 readonly backgroundColor: ColorValue;
446 readonly compact: boolean;
437 readonly fullSwipeAction?: "delete" | "primary";447 readonly fullSwipeAction?: "delete" | "primary";
438 readonly fullSwipeThreshold: number;448 readonly fullSwipeThreshold: number;
439 readonly onDelete: () => void;449 readonly onDelete: () => void;
...@@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: {...@@ -466,6 +476,7 @@ export function ThreadSwipeActions(props: {
466 <SwipeActionButton476 <SwipeActionButton
467 accessibilityLabel={props.primaryAction.accessibilityLabel}477 accessibilityLabel={props.primaryAction.accessibilityLabel}
468 backgroundColor="#007aff"478 backgroundColor="#007aff"
479 compact={props.compact}
469 entryRange={[ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]}480 entryRange={[ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]}
470 fullSwipeThreshold={props.fullSwipeThreshold}481 fullSwipeThreshold={props.fullSwipeThreshold}
471 icon={props.primaryAction.icon}482 icon={props.primaryAction.icon}
...@@ -477,6 +488,7 @@ export function ThreadSwipeActions(props: {...@@ -477,6 +488,7 @@ export function ThreadSwipeActions(props: {
477 <SwipeActionButton488 <SwipeActionButton
478 accessibilityLabel={`Delete ${props.threadTitle}`}489 accessibilityLabel={`Delete ${props.threadTitle}`}
479 backgroundColor="#ff2d55"490 backgroundColor="#ff2d55"
491 compact={props.compact}
480 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}492 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
481 fullSwipeThreshold={props.fullSwipeThreshold}493 fullSwipeThreshold={props.fullSwipeThreshold}
482 icon="trash"494 icon="trash"
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+450-45
...@@ -5,9 +5,12 @@ import type {...@@ -5,9 +5,12 @@ import type {
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6import { LegendList } from "@legendapp/list/react-native";6import { LegendList } from "@legendapp/list/react-native";
7import type { MenuAction } from "@react-native-menu/menu";7import 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";
9import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";12import 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";
11import { Gesture, GestureDetector } from "react-native-gesture-handler";14import { Gesture, GestureDetector } from "react-native-gesture-handler";
12import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";15import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
13import { useSafeAreaInsets } from "react-native-safe-area-context";16import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -22,7 +25,9 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -22,7 +25,9 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
22import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";25import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
23import { useThemeColor } from "../../lib/useThemeColor";26import { useThemeColor } from "../../lib/useThemeColor";
24import { useProjects, useThreadShells } from "../../state/entities";27import { 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";
26import { useWorkspaceState } from "../../state/workspace";31import { useWorkspaceState } from "../../state/workspace";
27import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";32import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
28import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";33import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
...@@ -59,6 +64,27 @@ import {...@@ -59,6 +64,27 @@ import {
59 ThreadListRow,64 ThreadListRow,
60 ThreadListShowMoreRow,65 ThreadListShowMoreRow,
61} from "./thread-list-items";66} 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
63/**89/**
64 * Shared capsule behind the sidebar header buttons — a native liquid-glass90 * Shared capsule behind the sidebar header buttons — a native liquid-glass
...@@ -169,7 +195,12 @@ function ThreadNavigationSidebarPane(...@@ -169,7 +195,12 @@ function ThreadNavigationSidebarPane(
169 const openSwipeableRef = useRef<SwipeableMethods | null>(null);195 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
170 const headerIsOverContentRef = useRef(false);196 const headerIsOverContentRef = useRef(false);
171 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);197 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;
173 const pendingTasks = usePendingNewTasks();204 const pendingTasks = usePendingNewTasks();
174 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();205 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
175 const environments = useMemo(206 const environments = useMemo(
...@@ -193,19 +224,77 @@ function ThreadNavigationSidebarPane(...@@ -193,19 +224,77 @@ function ThreadNavigationSidebarPane(
193 setProjectSortOrder,224 setProjectSortOrder,
194 setThreadSortOrder,225 setThreadSortOrder,
195 } = useHomeListOptions(availableEnvironmentIds);226 } = 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 );
196 const groups = useMemo(285 const groups = useMemo(
197 () =>286 () =>
198 buildHomeThreadGroups({287 buildHomeThreadGroups({
199 projects,288 projects: scopedProjects,
200 threads,289 threads: scopedThreads,
201 pendingTasks,290 pendingTasks: scopedPendingTasks,
202 environmentId: options.selectedEnvironmentId,291 environmentId: options.selectedEnvironmentId,
203 searchQuery: props.searchQuery,292 searchQuery: props.searchQuery,
204 projectSortOrder: options.projectSortOrder,293 projectSortOrder: options.projectSortOrder,
205 threadSortOrder: options.threadSortOrder,294 threadSortOrder: options.threadSortOrder,
206 projectGroupingMode: options.projectGroupingMode,295 projectGroupingMode: options.projectGroupingMode,
207 }),296 }),
208 [options, pendingTasks, projects, props.searchQuery, threads],297 [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads],
209 );298 );
210 const [groupDisplayStates, setGroupDisplayStates] = useState<299 const [groupDisplayStates, setGroupDisplayStates] = useState<
211 ReadonlyMap<string, HomeGroupDisplayState>300 ReadonlyMap<string, HomeGroupDisplayState>
...@@ -237,6 +326,153 @@ function ThreadNavigationSidebarPane(...@@ -237,6 +326,153 @@ function ThreadNavigationSidebarPane(
237 }326 }
238 return map;327 return map;
239 }, [projects]);328 }, [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 ]);
240 const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);476 const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState);
241 const listMenuActions = useMemo<MenuAction[]>(477 const listMenuActions = useMemo<MenuAction[]>(
242 () => [478 () => [
...@@ -260,36 +496,64 @@ function ThreadNavigationSidebarPane(...@@ -260,36 +496,64 @@ function ThreadNavigationSidebarPane(
260 })),496 })),
261 ],497 ],
262 },498 },
263 {499 ...(projectFilterOptions.length === 0
264 id: "project-sort",500 ? []
265 title: "Sort projects",501 : ([
266 subactions: PROJECT_SORT_OPTIONS.map((option) => ({502 {
267 id: `project-sort:${option.value}`,503 id: "project",
268 title: option.label,504 title: "Project",
269 state: options.projectSortOrder === option.value ? "on" : "off",505 subactions: [
270 })),506 {
271 },507 id: "project:all",
272 {508 title: "All projects",
273 id: "thread-sort",509 subtitle: "Show threads from every project",
274 title: "Sort threads",510 state: selectedProjectKey === null ? "on" : "off",
275 subactions: THREAD_SORT_OPTIONS.map((option) => ({511 },
276 id: `thread-sort:${option.value}`,512 ...projectFilterOptions.map((project) => ({
277 title: option.label,513 id: `project:${project.key}`,
278 state: options.threadSortOrder === option.value ? "on" : "off",514 title: project.label,
279 })),515 state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const),
280 },516 })),
281 {517 ],
282 id: "project-grouping",518 },
283 title: "Group projects",519 ] satisfies MenuAction[])),
284 subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({520 // v2 lays the list out in fixed creation order — offering sort/group
285 id: `project-grouping:${option.value}`,521 // controls it silently ignores would be a lie. Environment still
286 title: option.label,522 // scopes the v2 partition, so it stays.
287 subtitle: option.subtitle,523 ...(threadListV2Enabled
288 state: options.projectGroupingMode === option.value ? "on" : "off",524 ? []
289 })),525 : ([
290 },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[])),
291 ],555 ],
292 [environments, options],556 [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled],
293 );557 );
294 const handleListMenuAction = useCallback(558 const handleListMenuAction = useCallback(
295 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {559 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
...@@ -305,6 +569,17 @@ function ThreadNavigationSidebarPane(...@@ -305,6 +569,17 @@ function ThreadNavigationSidebarPane(
305 if (environment) setSelectedEnvironmentId(environment.environmentId);569 if (environment) setSelectedEnvironmentId(environment.environmentId);
306 return;570 return;
307 }571 }
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 }
308 const projectSort = PROJECT_SORT_OPTIONS.find(583 const projectSort = PROJECT_SORT_OPTIONS.find(
309 (option) => `project-sort:${option.value}` === event,584 (option) => `project-sort:${option.value}` === event,
310 );585 );
...@@ -326,6 +601,7 @@ function ThreadNavigationSidebarPane(...@@ -326,6 +601,7 @@ function ThreadNavigationSidebarPane(
326 },601 },
327 [602 [
328 environments,603 environments,
604 projectFilterOptions,
329 setProjectGroupingMode,605 setProjectGroupingMode,
330 setProjectSortOrder,606 setProjectSortOrder,
331 setSelectedEnvironmentId,607 setSelectedEnvironmentId,
...@@ -382,7 +658,44 @@ function ThreadNavigationSidebarPane(...@@ -382,7 +658,44 @@ function ThreadNavigationSidebarPane(
382 onScroll: handleScroll,658 onScroll: handleScroll,
383 onScrollBeginDrag: handleScrollBeginDrag,659 onScrollBeginDrag: handleScrollBeginDrag,
384 });660 });
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 );
386 const focusSearch = useCallback(() => {699 const focusSearch = useCallback(() => {
387 const focus = () => {700 const focus = () => {
388 if (props.nativeChrome) {701 if (props.nativeChrome) {
...@@ -401,8 +714,78 @@ function ThreadNavigationSidebarPane(...@@ -401,8 +714,78 @@ function ThreadNavigationSidebarPane(
401 }, [props.nativeChrome, props.onRequestVisibility, props.visible]);714 }, [props.nativeChrome, props.onRequestVisibility, props.visible]);
402 useHardwareKeyboardCommand("focusSearch", focusSearch);715 useHardwareKeyboardCommand("focusSearch", focusSearch);
403 const renderListItem = useCallback(716 const renderListItem = useCallback(
404 ({ item }: { readonly item: HomeListItem }) => {717 ({ item }: { readonly item: SidebarListItem }) => {
405 switch (item.type) {718 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 );
406 case "header":789 case "header":
407 return (790 return (
408 <ThreadListGroupHeader791 <ThreadListGroupHeader
...@@ -478,42 +861,61 @@ function ThreadNavigationSidebarPane(...@@ -478,42 +861,61 @@ function ThreadNavigationSidebarPane(
478 archiveThread,861 archiveThread,
479 confirmDeletePendingTask,862 confirmDeletePendingTask,
480 confirmDeleteThread,863 confirmDeleteThread,
864 handleChangeRequestState,
481 handleSelectThread,865 handleSelectThread,
482 handleSwipeableClose,866 handleSwipeableClose,
483 handleSwipeableWillOpen,867 handleSwipeableWillOpen,
484 openPendingTask,868 openPendingTask,
869 projectByKey,
485 projectCwdByKey,870 projectCwdByKey,
486 props.onNewThreadInProject,871 props.onNewThreadInProject,
487 props.selectedThreadKey,872 props.selectedThreadKey,
488 props.width,873 props.width,
489 savedConnectionsById,874 savedConnectionsById,
875 serverConfigs,
876 settleThread,
877 settlementEnvironmentIds,
878 showMoreSettled,
490 sidebarScrollGesture,879 sidebarScrollGesture,
880 unsettleThread,
491 updateGroupDisplay,881 updateGroupDisplay,
492 ],882 ],
493 );883 );
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
495 ? "line.3.horizontal.decrease.circle.fill"890 ? "line.3.horizontal.decrease.circle.fill"
496 : "line.3.horizontal.decrease.circle";891 : "line.3.horizontal.decrease.circle";
497 const filterMenu = useMemo(892 const filterMenu = useMemo(
498 () =>893 () =>
499 buildHomeListFilterMenu({894 buildHomeListFilterMenu({
500 environments,895 environments,
896 projects: projectFilterOptions,
501 selectedEnvironmentId: options.selectedEnvironmentId,897 selectedEnvironmentId: options.selectedEnvironmentId,
898 selectedProjectKey,
502 projectSortOrder: options.projectSortOrder,899 projectSortOrder: options.projectSortOrder,
503 threadSortOrder: options.threadSortOrder,900 threadSortOrder: options.threadSortOrder,
504 projectGroupingMode: options.projectGroupingMode,901 projectGroupingMode: options.projectGroupingMode,
505 onEnvironmentChange: setSelectedEnvironmentId,902 onEnvironmentChange: setSelectedEnvironmentId,
903 onProjectChange: setSelectedProjectKey,
506 onProjectSortOrderChange: setProjectSortOrder,904 onProjectSortOrderChange: setProjectSortOrder,
507 onThreadSortOrderChange: setThreadSortOrder,905 onThreadSortOrderChange: setThreadSortOrder,
508 onProjectGroupingModeChange: setProjectGroupingMode,906 onProjectGroupingModeChange: setProjectGroupingMode,
907 listOrganization: !threadListV2Enabled,
509 }),908 }),
510 [909 [
511 environments,910 environments,
512 options,911 options,
912 projectFilterOptions,
913 selectedProjectKey,
513 setProjectGroupingMode,914 setProjectGroupingMode,
514 setProjectSortOrder,915 setProjectSortOrder,
515 setSelectedEnvironmentId,916 setSelectedEnvironmentId,
516 setThreadSortOrder,917 setThreadSortOrder,
918 threadListV2Enabled,
517 ],919 ],
518 );920 );
519 const nativeHeaderItems = useMemo(921 const nativeHeaderItems = useMemo(
...@@ -531,7 +933,9 @@ function ThreadNavigationSidebarPane(...@@ -531,7 +933,9 @@ function ThreadNavigationSidebarPane(
531 ? "Loading threads…"933 ? "Loading threads…"
532 : props.searchQuery.trim().length > 0934 : props.searchQuery.trim().length > 0
533 ? "No matching threads"935 ? "No matching threads"
534 : "No threads yet"}936 : selectedProject !== null
937 ? `No threads in ${selectedProject.title}`
938 : "No threads yet"}
535 </Text>939 </Text>
536 );940 );
537941
...@@ -539,6 +943,7 @@ function ThreadNavigationSidebarPane(...@@ -539,6 +943,7 @@ function ThreadNavigationSidebarPane(
539 return (943 return (
540 <>944 <>
541 <NativeStackScreenOptions945 <NativeStackScreenOptions
946 optionsVersion={nativeHeaderItems}
542 options={{947 options={{
543 headerSearchBarOptions: {948 headerSearchBarOptions: {
544 ref: searchBarRef,949 ref: searchBarRef,
...@@ -564,12 +969,12 @@ function ThreadNavigationSidebarPane(...@@ -564,12 +969,12 @@ function ThreadNavigationSidebarPane(
564 <SwipeableScrollGateProvider enabled={swipeEnabled}>969 <SwipeableScrollGateProvider enabled={swipeEnabled}>
565 <GestureDetector gesture={sidebarScrollGesture}>970 <GestureDetector gesture={sidebarScrollGesture}>
566 <LegendList971 <LegendList
567 data={listLayout.items}972 data={listItems}
568 drawDistance={500}973 drawDistance={500}
569 estimatedItemSize={64}974 estimatedItemSize={64}
570 extraData={listExtraData}975 extraData={listExtraData}
571 getItemType={(item) => item.type}976 getItemType={(item) => item.type}
572 itemsAreEqual={homeListItemsAreEqual}977 itemsAreEqual={sidebarItemsAreEqual}
573 keyExtractor={(item) => item.key}978 keyExtractor={(item) => item.key}
574 renderItem={renderListItem}979 renderItem={renderListItem}
575 automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}980 automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}
...@@ -625,12 +1030,12 @@ function ThreadNavigationSidebarPane(...@@ -625,12 +1030,12 @@ function ThreadNavigationSidebarPane(
625 <SwipeableScrollGateProvider enabled={swipeEnabled}>1030 <SwipeableScrollGateProvider enabled={swipeEnabled}>
626 <GestureDetector gesture={sidebarScrollGesture}>1031 <GestureDetector gesture={sidebarScrollGesture}>
627 <LegendList1032 <LegendList
628 data={listLayout.items}1033 data={listItems}
629 drawDistance={500}1034 drawDistance={500}
630 estimatedItemSize={64}1035 estimatedItemSize={64}
631 extraData={listExtraData}1036 extraData={listExtraData}
632 getItemType={(item) => item.type}1037 getItemType={(item) => item.type}
633 itemsAreEqual={homeListItemsAreEqual}1038 itemsAreEqual={sidebarItemsAreEqual}
634 keyExtractor={(item) => item.key}1039 keyExtractor={(item) => item.key}
635 renderItem={renderListItem}1040 renderItem={renderListItem}
636 contentContainerStyle={[1041 contentContainerStyle={[
apps/mobile/src/features/threads/thread-list-v2-items.tsx+214-80
...@@ -19,8 +19,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";...@@ -19,8 +19,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions";
19import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";19import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";
2020
21/**21/**
22 * Thread List v2 rows mirror the web sidebar's compact tonal cards and22 * Thread List v2 renders one flat native list: rich edge-to-edge rows for
23 * receded settled tail while retaining native swipe and long-press actions.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.
24 */26 */
2527
26const MONO_FONT = Platform.select({28const MONO_FONT = Platform.select({
...@@ -29,12 +31,15 @@ const MONO_FONT = Platform.select({...@@ -29,12 +31,15 @@ const MONO_FONT = Platform.select({
29 default: "monospace",31 default: "monospace",
30});32});
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.
32const STATUS_LABEL_BY_STATUS: Partial<37const STATUS_LABEL_BY_STATUS: Partial<
33 Record<ThreadListV2Status, { label: string; className: string }>38 Record<ThreadListV2Status, { label: string; className: string }>
34> = {39> = {
35 approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" },40 approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" },
36 input: { label: "Input", className: "text-amber-700 dark:text-amber-300" },41 input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" },
37 working: { label: "Working", className: "text-blue-600 dark:text-blue-400" },42 working: { label: "Working", className: "text-sky-600 dark:text-sky-400" },
38 failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },43 failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
39};44};
4045
...@@ -60,10 +65,20 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [...@@ -60,10 +65,20 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [
60 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },65 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
61];66];
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}) {
64 const borderColor = useThemeColor("--color-border");74 const borderColor = useThemeColor("--color-border");
65 return (75 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 >
67 <Text className="text-xs font-t3-medium text-foreground-tertiary">Settled</Text>82 <Text className="text-xs font-t3-medium text-foreground-tertiary">Settled</Text>
68 <View className="h-px flex-1" style={{ backgroundColor: borderColor }} />83 <View className="h-px flex-1" style={{ backgroundColor: borderColor }} />
69 </View>84 </View>
...@@ -76,6 +91,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -76,6 +91,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
76 readonly showSettledDivider: boolean;91 readonly showSettledDivider: boolean;
77 readonly project: EnvironmentProject | null;92 readonly project: EnvironmentProject | null;
78 readonly providerDriver: string | null;93 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;
79 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;110 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
80 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;111 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
81 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;112 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
...@@ -117,6 +148,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -117,6 +148,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
117 }, [onChangeRequestState, prState, threadKey]);148 }, [onChangeRequestState, prState, threadKey]);
118149
119 const screenColor = useThemeColor("--color-screen");150 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
121 const status = resolveThreadListV2Status(thread);157 const status = resolveThreadListV2Status(thread);
122 const statusLabel = STATUS_LABEL_BY_STATUS[status];158 const statusLabel = STATUS_LABEL_BY_STATUS[status];
...@@ -174,99 +210,184 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -174,99 +210,184 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
174 thread.title,210 thread.title,
175 ]);211 ]);
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
177 const rowContent = (close: () => void) =>321 const rowContent = (close: () => void) =>
178 variant === "card" ? (322 variant === "card" ? (
179 <Pressable323 <Pressable
180 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}324 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
181 accessibilityLabel={thread.title}325 accessibilityLabel={thread.title}
182 accessibilityRole="button"326 accessibilityRole="button"
327 accessibilityState={{ selected }}
183 onPress={() => {328 onPress={() => {
184 close();329 close();
185 onSelectThread(thread);330 onSelectThread(thread);
186 }}331 }}
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 }
188 >346 >
189 <View className="bg-screen px-4 py-1">347 {sidebarPane ? (
190 <View348 cardContent
191 className={cn(349 ) : (
192 "overflow-hidden",350 /* Flat native list rows: no tonal containers — colored status
193 status === "ready"351 labels and text hierarchy carry state, an inset hairline
194 ? "bg-black/[0.025] dark:bg-white/[0.025]"352 separates rows. The opaque screen background stays so swipe
195 : "bg-black/[0.04] dark:bg-white/[0.04]",353 actions reveal behind the row. */
196 )}354 <View className="bg-screen">
197 style={{ borderRadius: 12, borderCurve: "continuous", minHeight: 84 }}355 <View className="px-5 py-2.5">{cardContent}</View>
198 >356 <View className="ml-5 h-px bg-border-subtle" />
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>
253 </View>357 </View>
254 </View>358 )}
255 </Pressable>359 </Pressable>
256 ) : (360 ) : (
257 <Pressable361 <Pressable
258 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}362 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
259 accessibilityLabel={thread.title}363 accessibilityLabel={thread.title}
260 accessibilityRole="button"364 accessibilityRole="button"
261 className="bg-screen"365 accessibilityState={{ selected }}
366 className={sidebarPane ? undefined : "bg-screen"}
262 onPress={() => {367 onPress={() => {
263 close();368 close();
264 onSelectThread(thread);369 onSelectThread(thread);
265 }}370 }}
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 }
267 >383 >
268 {/* Settled history recedes: dimmed favicon + muted title. */}384 {/* 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 >
270 {props.project ? (391 {props.project ? (
271 <View className="opacity-40">392 <View className="opacity-40">
272 <ProjectFavicon393 <ProjectFavicon
...@@ -277,11 +398,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -277,11 +398,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
277 />398 />
278 </View>399 </View>
279 ) : null}400 ) : 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 >
281 {thread.title}408 {thread.title}
282 </Text>409 </Text>
283 <Text410 <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 )}
285 style={{ fontFamily: MONO_FONT }}415 style={{ fontFamily: MONO_FONT }}
286 >416 >
287 {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}417 {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
...@@ -292,14 +422,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -292,14 +422,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
292422
293 return (423 return (
294 <>424 <>
295 {props.showSettledDivider ? <ThreadListV2SettledDivider /> : null}425 {props.showSettledDivider ? <ThreadListV2SettledDivider pane={props.pane} /> : null}
296 <ThreadSwipeable426 <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 }
298 enableTrackpadSwipe432 enableTrackpadSwipe
299 // Full swipe commits the advertised lifecycle action (Settle /433 // Full swipe commits the advertised lifecycle action (Settle /
300 // Un-settle), never the destructive delete.434 // Un-settle), never the destructive delete.
301 fullSwipeAction="primary"435 fullSwipeAction="primary"
302 fullSwipeWidth={windowWidth - 32}436 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
303 onDelete={handleDelete}437 onDelete={handleDelete}
304 onSwipeableClose={props.onSwipeableClose}438 onSwipeableClose={props.onSwipeableClose}
305 onSwipeableWillOpen={props.onSwipeableWillOpen}439 onSwipeableWillOpen={props.onSwipeableWillOpen}
apps/mobile/src/features/threads/threadListV2.ts+6
...@@ -12,6 +12,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";...@@ -12,6 +12,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
12 */12 */
13export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";13export 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
15export function resolveThreadListV2Status(21export function resolveThreadListV2Status(
16 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,22 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
17): ThreadListV2Status {23): ThreadListV2Status {
apps/mobile/src/native/StackHeader.tsx+9-2
...@@ -142,6 +142,13 @@ function stabilizeOptionFunctions(...@@ -142,6 +142,13 @@ function stabilizeOptionFunctions(
142142
143export function NativeStackScreenOptions(props: {143export function NativeStackScreenOptions(props: {
144 readonly options?: AppNativeStackNavigationOptions;144 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;
145 readonly listeners?: Record<string, (event: never) => void>;152 readonly listeners?: Record<string, (event: never) => void>;
146 readonly name?: string;153 readonly name?: string;
147}) {154}) {
...@@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: {...@@ -163,7 +170,7 @@ export function NativeStackScreenOptions(props: {
163 if (!navigation || !stableOptions) {170 if (!navigation || !stableOptions) {
164 return;171 return;
165 }172 }
166 const signature = optionsSignature(stableOptions);173 const signature = optionsSignature([stableOptions, props.optionsVersion]);
167 // Avoid re-entering navigation state when semantically equal options are174 // Avoid re-entering navigation state when semantically equal options are
168 // reapplied every layout (common when callers pass unstable object literals).175 // reapplied every layout (common when callers pass unstable object literals).
169 if (lastAppliedOptionsSignatureRef.current === signature) {176 if (lastAppliedOptionsSignatureRef.current === signature) {
...@@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: {...@@ -171,7 +178,7 @@ export function NativeStackScreenOptions(props: {
171 }178 }
172 lastAppliedOptionsSignatureRef.current = signature;179 lastAppliedOptionsSignatureRef.current = signature;
173 navigation.setOptions(stableOptions);180 navigation.setOptions(stableOptions);
174 }, [navigation, stableOptions]);181 }, [navigation, props.optionsVersion, stableOptions]);
175182
176 useEffect(() => {183 useEffect(() => {
177 if (!navigation || !props.listeners) {184 if (!navigation || !props.listeners) {
apps/web/src/components/AppSidebarLayout.tsx+4-5
...@@ -84,7 +84,7 @@ function SidebarControl() {...@@ -84,7 +84,7 @@ function SidebarControl() {
84 "pointer-events-auto",84 "pointer-events-auto",
85 isSidebarVisible &&85 isSidebarVisible &&
86 stageBackdropVariant &&86 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!",
88 )}88 )}
89 aria-label="Toggle main sidebar"89 aria-label="Toggle main sidebar"
90 />90 />
...@@ -164,10 +164,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -164,10 +164,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
164 <Sidebar164 <Sidebar
165 side="left"165 side="left"
166 collapsible="offcanvas"166 collapsible="offcanvas"
167 className={cn(167 data-app-sidebar=""
168 "border-r border-border text-foreground",168 data-sidebar-version={useSidebarV2 ? "v2" : "v1"}
169 useSidebarV2 ? "app-sidebar" : "bg-card",169 className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
170 )}
171 resizable={{170 resizable={{
172 maxWidth: sidebarMaximumWidth,171 maxWidth: sidebarMaximumWidth,
173 minWidth: THREAD_SIDEBAR_MIN_WIDTH,172 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
apps/web/src/components/ChatView.tsx+1
...@@ -5470,6 +5470,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5470,6 +5470,7 @@ function ChatViewContent(props: ChatViewProps) {
5470 )}5470 )}
5471 >5471 >
5472 <div5472 <div
5473 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
5473 className={cn(5474 className={cn(
5474 "chat-composer-lower-chrome relative z-10",5475 "chat-composer-lower-chrome relative z-10",
5475 isGitRepo5476 isGitRepo
apps/web/src/components/CommandPalette.logic.test.ts+28
...@@ -3,10 +3,38 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools...@@ -3,10 +3,38 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools
3import type { Thread } from "../types";3import type { Thread } from "../types";
4import {4import {
5 buildThreadActionItems,5 buildThreadActionItems,
6 enumerateCommandPaletteItems,
6 filterCommandPaletteGroups,7 filterCommandPaletteGroups,
7 type CommandPaletteGroup,8 type CommandPaletteGroup,
8} from "./CommandPalette.logic";9} 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
10const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");38const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
11const PROJECT_ID = ProjectId.make("project-1");39const PROJECT_ID = ProjectId.make("project-1");
1240
apps/web/src/components/CommandPalette.logic.ts+17-1
...@@ -1,4 +1,8 @@...@@ -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";
2import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";6import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import * as Arr from "effect/Array";7import * as Arr from "effect/Array";
4import * as Result from "effect/Result";8import * as Result from "effect/Result";
...@@ -52,6 +56,18 @@ export interface CommandPaletteView {...@@ -52,6 +56,18 @@ export interface CommandPaletteView {
52 readonly initialQuery?: string;56 readonly initialQuery?: string;
53}57}
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
55export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";71export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
5672
57export function filterBrowseEntries(input: {73export function filterBrowseEntries(input: {
apps/web/src/components/CommandPalette.tsx+56-34
...@@ -95,6 +95,7 @@ import {...@@ -95,6 +95,7 @@ import {
95 buildProjectActionItems,95 buildProjectActionItems,
96 buildRootGroups,96 buildRootGroups,
97 buildThreadActionItems,97 buildThreadActionItems,
98 enumerateCommandPaletteItems,
98 type CommandPaletteActionItem,99 type CommandPaletteActionItem,
99 type CommandPaletteSubmenuItem,100 type CommandPaletteSubmenuItem,
100 type CommandPaletteView,101 type CommandPaletteView,
...@@ -112,7 +113,7 @@ import { ProjectFavicon } from "./ProjectFavicon";...@@ -112,7 +113,7 @@ import { ProjectFavicon } from "./ProjectFavicon";
112import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";113import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
113import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";114import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
114import { resolveDefaultProviderModelSelection } from "../providerInstances";115import { resolveDefaultProviderModelSelection } from "../providerInstances";
115import { resolveShortcutCommand } from "../keybindings";116import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
116import {117import {
117 Command,118 Command,
118 CommandDialog,119 CommandDialog,
...@@ -686,29 +687,30 @@ function OpenCommandPaletteDialog(props: {...@@ -686,29 +687,30 @@ function OpenCommandPaletteDialog(props: {
686687
687 const projectThreadItems = useMemo(688 const projectThreadItems = useMemo(
688 () =>689 () =>
689 buildProjectActionItems({690 enumerateCommandPaletteItems(
690 projects,691 buildProjectActionItems({
691 valuePrefix: "new-thread-in",692 projects,
692 shortcutCommand: "chat.new",693 valuePrefix: "new-thread-in",
693 icon: (project) => (694 icon: (project) => (
694 <ProjectFavicon695 <ProjectFavicon
695 environmentId={project.environmentId}696 environmentId={project.environmentId}
696 cwd={project.workspaceRoot}697 cwd={project.workspaceRoot}
697 className={ITEM_ICON_CLASS}698 className={ITEM_ICON_CLASS}
698 />699 />
699 ),700 ),
700 runProject: async (project) => {701 runProject: async (project) => {
701 await startNewThreadInProjectFromContext(702 await startNewThreadInProjectFromContext(
702 {703 {
703 activeDraftThread,704 activeDraftThread,
704 activeThread: activeThread ?? undefined,705 activeThread: activeThread ?? undefined,
705 defaultProjectRef,706 defaultProjectRef,
706 handleNewThread,707 handleNewThread,
707 },708 },
708 scopeProjectRef(project.environmentId, project.id),709 scopeProjectRef(project.environmentId, project.id),
709 );710 );
710 },711 },
711 }),712 }),
713 ),
712 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],714 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
713 );715 );
714716
...@@ -997,7 +999,13 @@ function OpenCommandPaletteDialog(props: {...@@ -997,7 +999,13 @@ function OpenCommandPaletteDialog(props: {
997 : projectThreadItems;999 : projectThreadItems;
998 pushPaletteView({1000 pushPaletteView({
999 addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,1001 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 ],
1001 });1009 });
1002 }, [1010 }, [
1003 clearOpenIntent,1011 clearOpenIntent,
...@@ -1545,6 +1553,22 @@ function OpenCommandPaletteDialog(props: {...@@ -1545,6 +1553,22 @@ function OpenCommandPaletteDialog(props: {
1545 }1553 }
15461554
1547 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {1555 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
1548 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {1572 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
1549 event.preventDefault();1573 event.preventDefault();
1550 void submitAddProjectCloneFlow();1574 void submitAddProjectCloneFlow();
...@@ -1855,7 +1879,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1855,7 +1879,7 @@ function OpenCommandPaletteDialog(props: {
1855 <span className="truncate text-foreground text-sm">1879 <span className="truncate text-foreground text-sm">
1856 {remoteProjectContext.title}1880 {remoteProjectContext.title}
1857 </span>1881 </span>
1858 <span className="truncate text-muted-foreground/70 text-xs">1882 <span className="truncate text-muted-foreground/85 text-xs">
1859 {remoteProjectContext.description}1883 {remoteProjectContext.description}
1860 </span>1884 </span>
1861 </span>1885 </span>
...@@ -1896,37 +1920,35 @@ function OpenCommandPaletteDialog(props: {...@@ -1896,37 +1920,35 @@ function OpenCommandPaletteDialog(props: {
1896 <Kbd>1920 <Kbd>
1897 <ArrowDownIcon />1921 <ArrowDownIcon />
1898 </Kbd>1922 </Kbd>
1899 <span className={cn("text-muted-foreground/80")}>Navigate</span>1923 <span>Navigate</span>
1900 </KbdGroup>1924 </KbdGroup>
1901 {addProjectCloneFlow?.step === "repository" ? (1925 {addProjectCloneFlow?.step === "repository" ? (
1902 <KbdGroup className="items-center gap-1.5">1926 <KbdGroup className="items-center gap-1.5">
1903 <Kbd>Enter</Kbd>1927 <Kbd>Enter</Kbd>
1904 <span className={cn("text-muted-foreground/80")}>1928 <span>{remoteProjectButtonLabel ?? "Continue"}</span>
1905 {remoteProjectButtonLabel ?? "Continue"}
1906 </span>
1907 </KbdGroup>1929 </KbdGroup>
1908 ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? (1930 ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? (
1909 <KbdGroup className="items-center gap-1.5">1931 <KbdGroup className="items-center gap-1.5">
1910 <Kbd>Enter</Kbd>1932 <Kbd>Enter</Kbd>
1911 <span className={cn("text-muted-foreground/80")}>Select</span>1933 <span>Select</span>
1912 </KbdGroup>1934 </KbdGroup>
1913 ) : null}1935 ) : null}
1914 {isSubmenu ? (1936 {isSubmenu ? (
1915 <KbdGroup className="items-center gap-1.5">1937 <KbdGroup className="items-center gap-1.5">
1916 <Kbd>Backspace</Kbd>1938 <Kbd>Backspace</Kbd>
1917 <span className={cn("text-muted-foreground/80")}>Back</span>1939 <span>Back</span>
1918 </KbdGroup>1940 </KbdGroup>
1919 ) : null}1941 ) : null}
1920 <KbdGroup className="items-center gap-1.5">1942 <KbdGroup className="items-center gap-1.5">
1921 <Kbd>Esc</Kbd>1943 <Kbd>Esc</Kbd>
1922 <span className={cn("text-muted-foreground/80")}>Close</span>1944 <span>Close</span>
1923 </KbdGroup>1945 </KbdGroup>
1924 </div>1946 </div>
1925 {canOpenProjectFromFileManager ? (1947 {canOpenProjectFromFileManager ? (
1926 <Button1948 <Button
1927 variant="ghost"1949 variant="ghost"
1928 size="xs"1950 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"
1930 disabled={isPickingProjectFolder}1952 disabled={isPickingProjectFolder}
1931 onClick={() => {1953 onClick={() => {
1932 void handleOpenProjectFromFileManager();1954 void handleOpenProjectFromFileManager();
apps/web/src/components/CommandPaletteResults.tsx+2-2
...@@ -75,7 +75,7 @@ function DisabledCommandPaletteResultRow(props: {...@@ -75,7 +75,7 @@ function DisabledCommandPaletteResultRow(props: {
75 {props.item.titleLeadingContent}75 {props.item.titleLeadingContent}
76 <span className="truncate">{props.item.title}</span>76 <span className="truncate">{props.item.title}</span>
77 </span>77 </span>
78 <span className="truncate text-muted-foreground/70 text-xs">78 <span className="truncate text-muted-foreground/85 text-xs">
79 {props.item.description}79 {props.item.description}
80 </span>80 </span>
81 </span>81 </span>
...@@ -121,7 +121,7 @@ function CommandPaletteResultRow(props: {...@@ -121,7 +121,7 @@ function CommandPaletteResultRow(props: {
121 {props.item.titleLeadingContent}121 {props.item.titleLeadingContent}
122 <span className="truncate">{props.item.title}</span>122 <span className="truncate">{props.item.title}</span>
123 </span>123 </span>
124 <span className="truncate text-muted-foreground/70 text-xs">124 <span className="truncate text-muted-foreground/85 text-xs">
125 {props.item.description}125 {props.item.description}
126 </span>126 </span>
127 </span>127 </span>
apps/web/src/components/RightPanelTabs.tsx+2-2
...@@ -157,7 +157,7 @@ function RightPanelEmptyState(props: {...@@ -157,7 +157,7 @@ function RightPanelEmptyState(props: {
157 key={action.label}157 key={action.label}
158 type="button"158 type="button"
159 onClick={action.onClick}159 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"
161 >161 >
162 {content}162 {content}
163 </button>163 </button>
...@@ -166,7 +166,7 @@ function RightPanelEmptyState(props: {...@@ -166,7 +166,7 @@ function RightPanelEmptyState(props: {
166 const disabledCard = (166 const disabledCard = (
167 <button167 <button
168 type="button"168 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"
170 aria-disabled="true"170 aria-disabled="true"
171 >171 >
172 {content}172 {content}
apps/web/src/components/Sidebar.tsx+1-1
...@@ -2245,7 +2245,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2245,7 +2245,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2245 <SidebarMenuButton2245 <SidebarMenuButton
2246 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}2246 ref={isManualProjectSorting ? dragHandleProps?.setActivatorNodeRef : undefined}
2247 size="sm"2247 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 ${
2249 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"2249 isManualProjectSorting ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
2250 }`}2250 }`}
2251 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}2251 {...(isManualProjectSorting && dragHandleProps ? dragHandleProps.attributes : {})}
apps/web/src/components/SidebarV2.tsx+538-425
...@@ -10,13 +10,17 @@ import {...@@ -10,13 +10,17 @@ import {
10import type { ScopedThreadRef } from "@t3tools/contracts";10import type { ScopedThreadRef } from "@t3tools/contracts";
11import {11import {
12 CheckIcon,12 CheckIcon,
13 ChevronDownIcon,
13 CircleCheckIcon,14 CircleCheckIcon,
14 CircleDashedIcon,15 CircleDashedIcon,
15 CloudIcon,16 CircleAlertIcon,
17 FolderIcon,
16 FolderPlusIcon,18 FolderPlusIcon,
19 GitBranchIcon,
17 MessageSquareIcon,20 MessageSquareIcon,
18 PlusIcon,21 PlusIcon,
19 SearchIcon,22 SearchIcon,
23 ServerIcon,
20 SquarePenIcon,24 SquarePenIcon,
21 Undo2Icon,25 Undo2Icon,
22} from "lucide-react";26} from "lucide-react";
...@@ -82,22 +86,22 @@ import {...@@ -82,22 +86,22 @@ import {
82import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";86import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
83import { ProjectFavicon } from "./ProjectFavicon";87import { ProjectFavicon } from "./ProjectFavicon";
84import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";88import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
89import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
85import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";90import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
86import { primaryServerProvidersAtom } from "../state/server";91import { primaryServerProvidersAtom } from "../state/server";
87import { stackedThreadToast, toastManager } from "./ui/toast";92import { stackedThreadToast, toastManager } from "./ui/toast";
88import { CommandDialogTrigger } from "./ui/command";93import { CommandDialogTrigger } from "./ui/command";
89import { Kbd } from "./ui/kbd";94import { Kbd } from "./ui/kbd";
95import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
90import {96import {
91 SidebarContent,97 SidebarContent,
92 SidebarGroup,98 SidebarGroup,
93 SidebarMenu,
94 SidebarMenuButton,99 SidebarMenuButton,
95 SidebarMenuItem,
96 SidebarSeparator,100 SidebarSeparator,
97 useSidebar,101 useSidebar,
98} from "./ui/sidebar";102} from "./ui/sidebar";
99import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";103import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
100import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";104import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
101105
102// Settled-tail paging: recent history is the common lookup; the deep tail106// Settled-tail paging: recent history is the common lookup; the deep tail
103// stays behind an explicit Show more.107// stays behind an explicit Show more.
...@@ -114,6 +118,110 @@ function threadTimeLabel(thread: SidebarThreadSummary): string {...@@ -114,6 +118,110 @@ function threadTimeLabel(thread: SidebarThreadSummary): string {
114 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));118 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
115}119}
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
117const SidebarV2Row = memo(function SidebarV2Row(props: {225const SidebarV2Row = memo(function SidebarV2Row(props: {
118 thread: SidebarThreadSummary;226 thread: SidebarThreadSummary;
119 variant: "card" | "slim";227 variant: "card" | "slim";
...@@ -174,37 +282,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -174,37 +282,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
174 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });282 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
175 const status = resolveSidebarV2Status(thread);283 const status = resolveSidebarV2Status(thread);
176 const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected;284 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.
177 const topStatus =288 const topStatus =
178 status === "working"289 status === "working"
179 ? {290 ? {
180 label: "Working",291 label: "Working",
181 icon: "working" as const,292 icon: "working" as const,
182 className:293 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",
184 }295 }
185 : status === "approval"296 : status === "approval"
186 ? {297 ? {
187 label: "Approval",298 label: "Approval",
188 icon: null,299 icon: null,
189 className: "font-semibold text-amber-700 dark:text-amber-300",300 className: "text-amber-700 dark:text-amber-300",
190 }301 }
191 : status === "input"302 : status === "input"
192 ? {303 ? {
193 label: "Input",304 label: "Input",
194 icon: null,305 icon: null,
195 className: "font-semibold text-amber-700 dark:text-amber-300",306 className: "text-indigo-600 dark:text-indigo-300",
196 }307 }
197 : status === "failed"308 : status === "failed"
198 ? {309 ? {
199 label: "Failed",310 label: "Failed",
200 icon: null,311 icon: null,
201 className: "font-semibold text-red-700 dark:text-red-300",312 className: "text-red-700 dark:text-red-300",
202 }313 }
203 : isUnread314 : isUnread
204 ? {315 ? {
205 label: "Done",316 label: "Done",
206 icon: "done" as const,317 icon: "done" as const,
207 className: "font-semibold text-emerald-700 dark:text-emerald-300",318 className: "text-emerald-700 dark:text-emerald-300",
208 }319 }
209 : null;320 : null;
210321
...@@ -227,11 +338,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -227,11 +338,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
227 }, [onChangeRequestState, prState, threadKey]);338 }, [onChangeRequestState, prState, threadKey]);
228339
229 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;340 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
232 const isRemote =350 const isRemote =
233 props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;351 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
235 const handleClick = useCallback(366 const handleClick = useCallback(
236 (event: ReactMouseEvent) => {367 (event: ReactMouseEvent) => {
237 onThreadClick(event, threadRef);368 onThreadClick(event, threadRef);
...@@ -312,13 +443,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -312,13 +443,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
312 [openPrLink, pr],443 [openPrLink, pr],
313 );444 );
314445
315 const rowClassName = cn(446 // All Sidebar V2 rows share one surface model. Live threads used to look
316 "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left",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",
317 props.isActive452 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"
319 : isSelected454 : isSelected
320 ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]"455 ? "bg-sidebar-row-selected text-sidebar-foreground dark:inset-ring-1 dark:inset-ring-white/5"
321 : "hover:bg-accent/65",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",
322 );459 );
323460
324 const title = isRenaming ? (461 const title = isRenaming ? (
...@@ -332,30 +469,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -332,30 +469,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
332 onBlur={handleRenameBlur}469 onBlur={handleRenameBlur}
333 onClick={(event) => event.stopPropagation()}470 onClick={(event) => event.stopPropagation()}
334 onDoubleClick={(event) => event.stopPropagation()}471 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"
336 />473 />
337 ) : (474 ) : (
338 <span475 <span
339 className={cn(476 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",
341 variant === "card"479 variant === "card"
342 ? cn(480 ? cn(
343 "line-clamp-2 break-words",481 "truncate",
344 isUnread482 isUnread
345 ? "font-semibold text-foreground"483 ? "text-foreground"
346 : status !== "ready"484 : status !== "ready"
347 ? "font-semibold text-foreground/95"485 ? "text-foreground/95"
348 : shouldRecede486 : shouldRecede
349 ? "font-normal text-muted-foreground/75"487 ? "text-muted-foreground/80"
350 : "font-medium text-foreground/90",488 : "text-foreground/90",
351 )489 )
352 : cn(490 : cn(
353 "truncate transition-colors group-hover/v2-row:text-foreground",491 "truncate group-hover/v2-row:text-foreground",
354 props.isActive492 props.isActive
355 ? "text-foreground"493 ? "text-foreground"
356 : isUnread494 : isUnread
357 ? "font-medium text-muted-foreground"495 ? "text-muted-foreground"
358 : "text-muted-foreground/60",496 : "text-muted-foreground/70",
359 ),497 ),
360 )}498 )}
361 >499 >
...@@ -369,7 +507,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -369,7 +507,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
369 type="button"507 type="button"
370 onClick={handlePrClick}508 onClick={handlePrClick}
371 className={cn(509 className={cn(
372 "shrink-0 font-mono text-[10px] hover:underline",510 "shrink-0 font-mono text-xs hover:underline",
373 variant === "slim" && variantAction === "unsettle"511 variant === "slim" && variantAction === "unsettle"
374 ? props.isActive512 ? props.isActive
375 ? "text-muted-foreground/70"513 ? "text-muted-foreground/70"
...@@ -388,67 +526,74 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -388,67 +526,74 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
388 data-thread-item526 data-thread-item
389 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"527 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
390 >528 >
391 <div529 <Tooltip>
392 role="button"530 <TooltipTrigger
393 tabIndex={0}531 render={
394 data-testid="sidebar-v2-row-slim"532 <div
395 className={cn(rowClassName, "flex h-[34px] items-center gap-2.5 px-2.5")}533 role="button"
396 onClick={handleClick}534 tabIndex={0}
397 onDoubleClick={handleDoubleClick}535 data-testid="sidebar-v2-row-slim"
398 onKeyDown={handleKeyDown}536 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
399 onContextMenu={handleContextMenu}537 onClick={handleClick}
400 >538 onDoubleClick={handleDoubleClick}
401 {/* Settled history recedes: dimmed favicon at rest, restored on539 onKeyDown={handleKeyDown}
402 hover so the tail stays scannable when you're hunting. */}540 onContextMenu={handleContextMenu}
403 <span541 />
404 className={cn(542 }
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 )}
409 >543 >
410 <ProjectFavicon544 {/* Settled history recedes: dimmed favicon at rest, restored on
411 environmentId={thread.environmentId}545 hover so the tail stays scannable when you're hunting. */}
412 cwd={props.projectCwd ?? ""}546 <span
413 className="size-3.5"547 className={cn(
414 fallbackIcon={MessageSquareIcon}548 "shrink-0 transition-opacity",
415 />549 !props.isActive &&
416 </span>550 "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0",
417 {title}551 )}
418 {/* The PR badge stays outside the hover-fading slot: it must552 >
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
419 remain visible AND clickable while the row is hovered. Only562 remain visible AND clickable while the row is hovered. Only
420 the time/jump label yields to the settle affordance. */}563 the time/jump label yields to the settle affordance. */}
421 {prBadge}564 {prBadge}
422 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">565 <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">566 <span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
424 <span className="text-[13px]">567 <span className="text-xs">
425 {props.jumpLabel ??568 {props.jumpLabel ??
426 compactSidebarTimeLabel(569 compactSidebarTimeLabel(
427 formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt),570 formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt),
428 )}571 )}
572 </span>
429 </span>573 </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 )}
430 </span>593 </span>
431 {!props.settlementSupported ? null : variantAction === "unsettle" ? (594 </TooltipTrigger>
432 <button595 {detailsTooltip}
433 type="button"596 </Tooltip>
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>
452 </li>597 </li>
453 );598 );
454 }599 }
...@@ -460,144 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -460,144 +605,109 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
460 data-thread-item605 data-thread-item
461 className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]"606 className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]"
462 >607 >
463 <div608 <Tooltip>
464 role="button"609 <TooltipTrigger
465 tabIndex={0}610 render={
466 data-testid="sidebar-v2-row-card"611 <div
467 className={cn(612 role="button"
468 // Every card carries a faint tonal fill so threads read as613 tabIndex={0}
469 // discrete objects; active/selected/hover are brighter tones of614 data-testid="sidebar-v2-row-card"
470 // the same treatment rather than a different shape.615 className={rowSurfaceClassName}
471 "group/v2-row relative w-full cursor-pointer select-none overflow-hidden rounded-lg text-left transition-colors",616 onClick={handleClick}
472 props.isActive617 onDoubleClick={handleDoubleClick}
473 ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]"618 onKeyDown={handleKeyDown}
474 : isSelected619 onContextMenu={handleContextMenu}
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"
491 />620 />
492 {props.projectTitle ? (621 }
493 <span622 >
494 className={cn(623 <div className="relative z-10 h-[4.875rem] px-2.5 py-2">
495 "min-w-0 flex-1 truncate text-[13px] leading-5 text-muted-foreground/70",624 <div className="flex h-5 min-w-0 items-center gap-1.5">
496 isUnread || status !== "ready"625 <ProjectFavicon
497 ? "font-semibold"626 environmentId={thread.environmentId}
498 : shouldRecede627 cwd={props.projectCwd ?? ""}
499 ? "font-normal"628 className="size-4 shrink-0"
500 : "font-medium",629 />
501 )}630 {props.projectTitle ? (
502 >631 <span className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground/85">
503 {props.projectTitle}632 {props.projectTitle}
504 </span>633 </span>
505 ) : (634 ) : (
506 <span className="flex-1" />635 <span className="flex-1" />
507 )}636 )}
508 <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-[13px]">637 <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-xs">
509 <span className="tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">638 <span className="tabular-nums text-muted-foreground/65 transition-opacity group-hover/v2-row:opacity-0">
510 {props.jumpLabel ? (639 {props.jumpLabel ? (
511 props.jumpLabel640 props.jumpLabel
512 ) : topStatus ? (641 ) : topStatus ? (
513 <span642 <span
514 role="status"643 role="status"
515 className={cn(644 className={cn(
516 "inline-flex items-center gap-1 text-[11px]",645 "inline-flex items-center gap-1 font-medium",
517 topStatus.className,646 topStatus.className,
518 )}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"
519 >666 >
520 {topStatus.icon === "working" ? (667 <CheckIcon className="size-3" />
521 <CircleDashedIcon aria-hidden className="size-3" />668 Settle
522 ) : topStatus.icon === "done" ? (669 </button>
523 <CircleCheckIcon aria-hidden className="size-3" />670 ) : null}
524 ) : null}
525 {topStatus.label}
526 </span>
527 ) : (
528 threadTimeLabel(thread)
529 )}
530 </span>671 </span>
531 {props.settlementSupported ? (672 </div>
532 <button673 <div className="mt-1 flex min-w-0">{title}</div>
533 type="button"674 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">
534 aria-label="Settle thread"675 {thread.branch ? (
535 onClick={handleSettleClick}676 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
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"677 ) : (
537 >678 <span className="flex-1" />
538 <CheckIcon className="size-3" />679 )}
539 Settle680 {prBadge}
540 </button>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>
541 ) : null}686 ) : null}
542 </span>687 <span className="ml-auto inline-flex shrink-0 items-center gap-1">
543 </div>688 {isRemote ? (
544 <div className="mt-1 flex min-w-0">{title}</div>689 <span
545 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground/65">690 aria-label={props.environmentLabel ?? "Remote environment"}
546 {thread.branch ? (691 className="inline-flex shrink-0 items-center text-sidebar-muted-foreground/70"
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" />}
565 >692 >
693 <ServerIcon aria-hidden className="size-4" />
694 </span>
695 ) : null}
696 {driverKind ? (
697 <span className="inline-flex shrink-0 items-center opacity-60">
566 <ProviderInstanceIcon698 <ProviderInstanceIcon
567 driverKind={driverKind}699 driverKind={driverKind}
568 displayName={thread.session?.providerName ?? modelInstanceId}700 displayName={thread.session?.providerName ?? modelInstanceId}
569 iconClassName="size-3"701 iconClassName="size-4"
570 />702 />
571 </TooltipTrigger>703 </span>
572 <TooltipPopup side="top">{thread.modelSelection.model}</TooltipPopup>704 ) : null}
573 </Tooltip>705 </span>
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}
597 </div>706 </div>
598 ) : null}707 </div>
599 </div>708 </TooltipTrigger>
600 </div>709 {detailsTooltip}
710 </Tooltip>
601 </li>711 </li>
602 );712 );
603});713});
...@@ -711,9 +821,8 @@ export default function SidebarV2() {...@@ -711,9 +821,8 @@ export default function SidebarV2() {
711 [],821 [],
712 );822 );
713823
714 // Project scope: chips above the list. Scoping filters the list AND824 // Project scope: one menu above the list. Scoping filters the list without
715 // becomes the new-thread target — one visible control doing both jobs the825 // making the header width depend on the number or length of project names.
716 // old per-project headers did.
717 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);826 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
718 const scopedProject = useMemo(827 const scopedProject = useMemo(
719 () =>828 () =>
...@@ -794,7 +903,7 @@ export default function SidebarV2() {...@@ -794,7 +903,7 @@ export default function SidebarV2() {
794 // filter context changes so a scope/search flip never inherits a deep903 // filter context changes so a scope/search flip never inherits a deep
795 // page state.904 // page state.
796 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);905 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
797 const settledResetKey = `${projectScopeKey ?? "all"}`;906 const settledResetKey = projectScopeKey ?? "all";
798 const lastSettledResetKeyRef = useRef(settledResetKey);907 const lastSettledResetKeyRef = useRef(settledResetKey);
799 if (lastSettledResetKeyRef.current !== settledResetKey) {908 if (lastSettledResetKeyRef.current !== settledResetKey) {
800 lastSettledResetKeyRef.current = settledResetKey;909 lastSettledResetKeyRef.current = settledResetKey;
...@@ -1322,243 +1431,245 @@ export default function SidebarV2() {...@@ -1322,243 +1431,245 @@ export default function SidebarV2() {
1322 const newThreadShortcutLabel =1431 const newThreadShortcutLabel =
1323 shortcutLabelForCommand(keybindings, "chat.newLocal") ??1432 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1324 shortcutLabelForCommand(keybindings, "chat.new");1433 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
1344 return (1434 return (
1345 <>1435 <>
1346 <SidebarChromeHeader isElectron={isElectron} />1436 <SidebarChromeHeader isElectron={isElectron} />
1347 <SidebarContent className="gap-0">1437 <SidebarContent className="gap-0">
1348 <SidebarGroup className="px-2 pb-2 pt-3">1438 <SidebarGroup className="px-2 pb-2 pt-3">
1349 <SidebarMenu className="flex-row gap-1">1439 <div className="flex items-center gap-1">
1350 <SidebarMenuItem className="min-w-0 flex-1">1440 <div className="min-w-0 flex-1">
1351 <CommandDialogTrigger1441 <CommandDialogTrigger
1352 render={1442 render={
1353 <SidebarMenuButton1443 <SidebarMenuButton
1354 size="sm"1444 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"
1356 data-testid="command-palette-trigger"1448 data-testid="command-palette-trigger"
1357 />1449 />
1358 }1450 }
1359 >1451 >
1360 <SearchIcon className="size-3.5 text-muted-foreground/70" />1452 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1361 <span className="flex-1 truncate text-left text-xs">Search</span>1453 <div className="flex-1 truncate text-left">Search</div>
1362 {commandPaletteShortcutLabel ? (1454 {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">
1364 {commandPaletteShortcutLabel}1456 {commandPaletteShortcutLabel}
1365 </Kbd>1457 </Kbd>
1366 ) : null}1458 ) : null}
1367 </CommandDialogTrigger>1459 </CommandDialogTrigger>
1368 </SidebarMenuItem>1460 </div>
1369 <SidebarMenuItem className="shrink-0">1461 <div className="shrink-0">
1370 <SidebarMenuButton1462 <Tooltip>
1371 size="sm"1463 <TooltipTrigger
1372 className="size-7 justify-center border border-border bg-background/60 p-0 text-muted-foreground/70 hover:bg-accent hover:text-foreground"1464 render={
1373 onClick={handleNewThreadClick}1465 <SidebarMenuButton
1374 disabled={projects.length === 0}1466 size="sm"
1375 aria-label="New thread"1467 type="button"
1376 tooltip={{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"
1377 children: newThreadShortcutLabel1469 onClick={handleNewThreadClick}
1378 ? `New thread (${newThreadShortcutLabel})`1470 disabled={projects.length === 0}
1379 : "New thread",1471 aria-label="New thread"
1380 side: "right",1472 />
1381 }}1473 }
1382 >1474 >
1383 <SquarePenIcon className="size-3.5 text-muted-foreground/70" />1475 <SquarePenIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1384 </SidebarMenuButton>1476 <span
1385 </SidebarMenuItem>1477 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
1386 </SidebarMenu>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>
1387 </SidebarGroup>1487 </SidebarGroup>
1388 {projects.length > 0 ? (1488 {projects.length > 0 ? (
1389 <SidebarGroup className="px-2 pb-1 pt-1">1489 <SidebarGroup className="px-2 pb-2 pt-0">
1390 <div className="relative">1490 <div className="flex items-center gap-1">
1391 <div1491 <Menu>
1392 ref={projectScrollerRef}1492 <MenuTrigger
1393 onScroll={updateProjectScrollFade}1493 aria-label="Filter threads by project"
1394 className={cn(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"
1395 "flex items-center gap-1 overflow-x-auto pr-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",1495 >
1396 canScrollProjectsRight &&1496 {scopedProject ? (
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))]",1497 <ProjectFavicon
1398 )}1498 environmentId={scopedProject.environmentId}
1399 role="tablist"1499 cwd={scopedProject.workspaceRoot}
1400 aria-label="Filter threads by project"1500 className="size-4 shrink-0"
1401 >1501 />
1402 {projects.length > 1 ? (1502 ) : (
1403 <button1503 <FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
1404 type="button"1504 )}
1405 role="tab"1505 <span className="min-w-0 flex-1 truncate">
1406 aria-selected={projectScopeKey === null}1506 {scopedProject?.title ?? "All projects"}
1407 onClick={() => setProjectScopeKey(null)}1507 </span>
1408 className={cn(1508 <ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" />
1409 "shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors",1509 </MenuTrigger>
1410 projectScopeKey === null1510 <MenuPopup align="start" className="w-(--anchor-width)">
1411 ? "border-foreground/15 bg-accent text-foreground"1511 <MenuRadioGroup
1412 : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40",1512 value={projectScopeKey ?? "all"}
1413 )}1513 onValueChange={(value) =>
1414 >1514 setProjectScopeKey(value === "all" ? null : (value as string))
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 />
1455 }1515 }
1456 >1516 >
1457 <FolderPlusIcon className="size-3.5" />1517 <MenuRadioItem
1458 </TooltipTrigger>1518 value="all"
1459 <TooltipPopup side="bottom">Add project</TooltipPopup>1519 closeOnClick
1460 </Tooltip>1520 className="[&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"
1461 </div>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>
1462 </div>1566 </div>
1463 </SidebarGroup>1567 </SidebarGroup>
1464 ) : null}1568 ) : null}
1465 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">1569 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">
1466 <ul ref={attachListAutoAnimateRef} className="flex flex-col gap-px">1570 <TooltipProvider
1467 {orderedThreads.flatMap((thread, threadIndex) => {1571 key="sidebar-thread-tooltips-150"
1468 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));1572 delay={150}
1469 const isSettledRow = settledThreadKeys.has(threadKey);1573 closeDelay={0}
1470 // Settled is the ONLY thing that collapses a row: every1574 timeout={400}
1471 // not-settled thread is a full card. Density comes from users1575 >
1472 // (or the auto rules) actually settling work, not from the1576 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
1473 // sidebar second-guessing what still matters.1577 {orderedThreads.flatMap((thread, threadIndex) => {
1474 const isCard = !isSettledRow;1578 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1475 const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null;1579 const isSettledRow = settledThreadKeys.has(threadKey);
1476 const previousWasCard =1580 // Settled is the ONLY thing that collapses a row: every
1477 previousThread != null &&1581 // not-settled thread is a full card. Density comes from users
1478 !settledThreadKeys.has(1582 // (or the auto rules) actually settling work, not from the
1479 scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)),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 />
1480 );1635 );
1481 const showSettledGap = !isCard && previousWasCard;1636 if (!showSettledGap) return [row];
1482 const row = (1637 // The divider is its own keyed list item (not part of the first
1483 <SidebarV2Row1638 // settled row): it keeps one stable DOM node at the boundary,
1484 // Keyed per variant on purpose: when a thread settles, the1639 // so settling a thread slides it instead of teleporting it
1485 // card fades out in place and the slim row fades in at its1640 // along with whichever row happens to be first in the tail —
1486 // settled position instead of one element FLIP-sliding1641 // and row heights stay independent of neighbor classification.
1487 // through every row in between (rows here are translucent,1642 return [
1488 // so a crossing row reads as text painted over text).1643 <li
1489 key={`${threadKey}:${isCard ? "card" : "slim"}`}1644 key="settled-divider"
1490 thread={thread}1645 aria-hidden
1491 variant={isCard ? "card" : "slim"}1646 data-thread-selection-safe
1492 // Every settled row can un-settle: explicit settles clear1647 className="list-none"
1493 // the override, auto-settled rows get pinned active.1648 >
1494 variantAction={isSettledRow ? "unsettle" : "settle"}1649 <div className="mb-1 mt-3 flex items-center gap-2 px-2.5">
1495 settlementSupported={1650 <span className="text-xs font-medium text-muted-foreground/50">Settled</span>
1496 serverConfigs.get(thread.environmentId)?.environment.capabilities1651 <span className="h-px flex-1 bg-sidebar-border/60" />
1497 .threadSettlement === true1652 </div>
1498 }1653 </li>,
1499 isActive={routeThreadKey === threadKey}1654 row,
1500 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}1655 ];
1501 currentEnvironmentId={primaryEnvironmentId}1656 })}
1502 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}1657 {hiddenSettledCount > 0 ? (
1503 projectCwd={1658 <li className="list-none">
1504 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null1659 <button
1505 }1660 type="button"
1506 projectTitle={1661 onClick={showMoreSettled}
1507 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null1662 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"
1508 }1663 >
1509 providerEntryByInstanceId={providerEntryByInstanceId}1664 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
1510 onThreadClick={handleThreadClick}1665 <span className="text-muted-foreground/50">
1511 onThreadActivate={navigateToThread}1666 ({hiddenSettledCount} settled hidden)
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
1540 </span>1667 </span>
1541 <span className="h-px flex-1 bg-border/60" />1668 </button>
1542 </div>1669 </li>
1543 </li>,1670 ) : null}
1544 row,1671 </ul>
1545 ];1672 </TooltipProvider>
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>
1562 {orderedThreads.length === 0 ? (1673 {orderedThreads.length === 0 ? (
1563 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">1674 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
1564 {projects.length === 0 ? (1675 {projects.length === 0 ? (
...@@ -1567,7 +1678,7 @@ export default function SidebarV2() {...@@ -1567,7 +1678,7 @@ export default function SidebarV2() {
1567 <button1678 <button
1568 type="button"1679 type="button"
1569 onClick={openAddProjectCommandPalette}1680 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"
1571 >1682 >
1572 <PlusIcon className="size-3" />1683 <PlusIcon className="size-3" />
1573 Add project1684 Add project
...@@ -1582,7 +1693,9 @@ export default function SidebarV2() {...@@ -1582,7 +1693,9 @@ export default function SidebarV2() {
1582 ) : null}1693 ) : null}
1583 </SidebarGroup>1694 </SidebarGroup>
1584 </SidebarContent>1695 </SidebarContent>
1585 <SidebarSeparator />1696 <div className="shrink-0 px-2">
1697 <SidebarSeparator className="mx-0 w-full bg-sidebar-border/80" />
1698 </div>
1586 <SidebarChromeFooter />1699 <SidebarChromeFooter />
1587 </>1700 </>
1588 );1701 );
apps/web/src/components/chat/ChatComposer.tsx+1-1
...@@ -2165,7 +2165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2165,7 +2165,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2165 "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40",2165 "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40",
2166 isDragOverComposer2166 isDragOverComposer
2167 ? "border-primary/70 bg-accent/45"2167 ? "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",
2169 projectSelectionRequired ? "opacity-75" : null,2169 projectSelectionRequired ? "opacity-75" : null,
2170 composerProviderState.composerSurfaceClassName,2170 composerProviderState.composerSurfaceClassName,
2171 )}2171 )}
apps/web/src/components/chat/ModelPickerContent.tsx+3-3
...@@ -521,7 +521,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -521,7 +521,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
521 return (521 return (
522 <TooltipProvider delay={0}>522 <TooltipProvider delay={0}>
523 <div523 <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"
525 data-model-picker-content="true"525 data-model-picker-content="true"
526 >526 >
527 {/* Sidebar */}527 {/* Sidebar */}
...@@ -580,11 +580,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -580,11 +580,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
580 <ComboboxInput580 <ComboboxInput
581 ref={searchInputRef}581 ref={searchInputRef}
582 className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5"582 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"
584 placeholder="Search models..."584 placeholder="Search models..."
585 showTrigger={false}585 showTrigger={false}
586 startAddon={586 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" />
588 }588 }
589 value={searchQuery}589 value={searchQuery}
590 onChange={(e) => setSearchQuery(e.target.value)}590 onChange={(e) => setSearchQuery(e.target.value)}
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+1-1
...@@ -28,7 +28,7 @@ function ConfiguredT3ConnectSidebarAvatar() {...@@ -28,7 +28,7 @@ function ConfiguredT3ConnectSidebarAvatar() {
28 appearance={{28 appearance={{
29 elements: {29 elements: {
30 avatarBox: "size-7",30 avatarBox: "size-7",
31 userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent",31 userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-row-hover",
32 },32 },
33 }}33 }}
34 >34 >
apps/web/src/components/settings/SettingsSidebarNav.tsx+5-5
...@@ -85,16 +85,16 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -85,16 +85,16 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
85 isActive={isActive}85 isActive={isActive}
86 className={86 className={
87 isActive87 isActive
88 ? "gap-2.5 px-2.5 py-2 text-left text-[13px] font-medium text-foreground"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-muted-foreground/70 hover:text-foreground/80"89 : "gap-2.5 px-2.5 py-2 text-left text-[13px] text-sidebar-muted-foreground hover:text-sidebar-foreground"
90 }90 }
91 onClick={() => handleSectionClick(item.to)}91 onClick={() => handleSectionClick(item.to)}
92 >92 >
93 <Icon93 <Icon
94 className={94 className={
95 isActive95 isActive
96 ? "size-4 shrink-0 text-foreground"96 ? "size-4 shrink-0 text-sidebar-foreground"
97 : "size-4 shrink-0 text-muted-foreground/60"97 : "size-4 shrink-0 text-sidebar-muted-foreground"
98 }98 }
99 />99 />
100 <span className="truncate">{item.label}</span>100 <span className="truncate">{item.label}</span>
...@@ -114,7 +114,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -114,7 +114,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
114 <SidebarMenuItem>114 <SidebarMenuItem>
115 <SidebarMenuButton115 <SidebarMenuButton
116 size="sm"116 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"
118 onClick={handleBackClick}118 onClick={handleBackClick}
119 >119 >
120 <ArrowLeftIcon className="size-4" />120 <ArrowLeftIcon className="size-4" />
apps/web/src/components/settings/settingsLayout.tsx+1-1
...@@ -38,7 +38,7 @@ export function SettingsSection({...@@ -38,7 +38,7 @@ export function SettingsSection({
38 </h2>38 </h2>
39 <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div>39 <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div>
40 </div>40 </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%)]">
42 {children}42 {children}
43 </div>43 </div>
44 </section>44 </section>
apps/web/src/components/sidebar/SidebarChrome.tsx+5-4
...@@ -39,7 +39,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -39,7 +39,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
39 <SidebarTrigger39 <SidebarTrigger
40 className={cn(40 className={cn(
41 "relative z-10 md:hidden",41 "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!",
43 )}44 )}
44 />45 />
45 <SidebarBrand onBackdrop={backdropVariant !== null} />46 <SidebarBrand onBackdrop={backdropVariant !== null} />
...@@ -114,11 +115,11 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -114,11 +115,11 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
114 <SidebarMenuItem>115 <SidebarMenuItem>
115 <SidebarMenuButton116 <SidebarMenuButton
116 size="sm"117 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"
118 onClick={handleSettingsClick}119 onClick={handleSettingsClick}
119 >120 >
120 <SettingsIcon className="size-3.5" />121 <SettingsIcon className="size-4 shrink-0" />
121 <span className="text-xs">Settings</span>122 <span>Settings</span>
122 </SidebarMenuButton>123 </SidebarMenuButton>
123 </SidebarMenuItem>124 </SidebarMenuItem>
124 </SidebarMenu>125 </SidebarMenu>
apps/web/src/components/ui/command.tsx+5-5
...@@ -66,7 +66,7 @@ function CommandDialogPopup({...@@ -66,7 +66,7 @@ function CommandDialogPopup({
66 <CommandDialogViewport>66 <CommandDialogViewport>
67 <CommandDialogPrimitive.Popup67 <CommandDialogPrimitive.Popup
68 className={cn(68 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",
70 className,70 className,
71 )}71 )}
72 data-slot="command-dialog-popup"72 data-slot="command-dialog-popup"
...@@ -108,7 +108,7 @@ function CommandInput({...@@ -108,7 +108,7 @@ function CommandInput({
108 <AutocompleteInput108 <AutocompleteInput
109 autoFocus109 autoFocus
110 className={cn(110 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",
112 className,112 className,
113 )}113 )}
114 placeholder={placeholder}114 placeholder={placeholder}
...@@ -144,7 +144,7 @@ function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {...@@ -144,7 +144,7 @@ function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
144 return (144 return (
145 <div145 <div
146 className={cn(146 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",
148 className,148 className,
149 )}149 )}
150 {...props}150 {...props}
...@@ -192,7 +192,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {...@@ -192,7 +192,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
192 return (192 return (
193 <kbd193 <kbd
194 className={cn(194 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",
196 className,196 className,
197 )}197 )}
198 data-slot="command-shortcut"198 data-slot="command-shortcut"
...@@ -205,7 +205,7 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {...@@ -205,7 +205,7 @@ function CommandFooter({ className, ...props }: React.ComponentProps<"div">) {
205 return (205 return (
206 <div206 <div
207 className={cn(207 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",
209 className,209 className,
210 )}210 )}
211 data-slot="command-footer"211 data-slot="command-footer"
apps/web/src/components/ui/sidebar.tsx+10-10
...@@ -676,7 +676,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {...@@ -676,7 +676,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
676function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {676function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
677 return (677 return (
678 <Separator678 <Separator
679 className={cn("mx-2 w-auto bg-sidebar-border", className)}679 className={cn("mx-2 w-auto! bg-sidebar-border", className)}
680 data-sidebar="separator"680 data-sidebar="separator"
681 data-slot="sidebar-separator"681 data-slot="sidebar-separator"
682 {...props}682 {...props}
...@@ -732,7 +732,7 @@ function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentP...@@ -732,7 +732,7 @@ function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentP
732function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) {732function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
733 const defaultProps = {733 const defaultProps = {
734 className: cn(734 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",
736 // Increases the hit area of the button on mobile.736 // Increases the hit area of the button on mobile.
737 "after:-inset-2 after:absolute md:after:hidden",737 "after:-inset-2 after:absolute md:after:hidden",
738 "group-data-[collapsible=icon]:hidden",738 "group-data-[collapsible=icon]:hidden",
...@@ -783,7 +783,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {...@@ -783,7 +783,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
783}783}
784784
785const sidebarMenuButtonVariants = cva(785const 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",
787 {787 {
788 defaultVariants: {788 defaultVariants: {
789 size: "default",789 size: "default",
...@@ -796,9 +796,9 @@ const sidebarMenuButtonVariants = cva(...@@ -796,9 +796,9 @@ const sidebarMenuButtonVariants = cva(
796 sm: "h-7 text-xs",796 sm: "h-7 text-xs",
797 },797 },
798 variant: {798 variant: {
799 default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",799 default: "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
800 outline:800 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",
802 },802 },
803 },803 },
804 },804 },
...@@ -867,7 +867,7 @@ function SidebarMenuAction({...@@ -867,7 +867,7 @@ function SidebarMenuAction({
867}) {867}) {
868 const defaultProps = {868 const defaultProps = {
869 className: cn(869 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",
871 // Increases the hit area of the button on mobile.871 // Increases the hit area of the button on mobile.
872 "after:-inset-2 after:absolute md:after:hidden",872 "after:-inset-2 after:absolute md:after:hidden",
873 "peer-data-[size=sm]/menu-button:top-1",873 "peer-data-[size=sm]/menu-button:top-1",
...@@ -875,7 +875,7 @@ function SidebarMenuAction({...@@ -875,7 +875,7 @@ function SidebarMenuAction({
875 "peer-data-[size=lg]/menu-button:top-2.5",875 "peer-data-[size=lg]/menu-button:top-2.5",
876 "group-data-[collapsible=icon]:hidden",876 "group-data-[collapsible=icon]:hidden",
877 showOnHover &&877 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",
879 className,879 className,
880 ),880 ),
881 "data-sidebar": "menu-action",881 "data-sidebar": "menu-action",
...@@ -894,7 +894,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">)...@@ -894,7 +894,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">)
894 <div894 <div
895 className={cn(895 className={cn(
896 "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",896 "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",
898 "peer-data-[size=sm]/menu-button:top-1",898 "peer-data-[size=sm]/menu-button:top-1",
899 "peer-data-[size=default]/menu-button:top-1.5",899 "peer-data-[size=default]/menu-button:top-1.5",
900 "peer-data-[size=lg]/menu-button:top-2.5",900 "peer-data-[size=lg]/menu-button:top-2.5",
...@@ -979,8 +979,8 @@ function SidebarMenuSubButton({...@@ -979,8 +979,8 @@ function SidebarMenuSubButton({
979}) {979}) {
980 const defaultProps = {980 const defaultProps = {
981 className: cn(981 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",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-accent data-[active=true]:text-sidebar-accent-foreground",983 "data-[active=true]:bg-sidebar-row-selected data-[active=true]:text-sidebar-foreground",
984 size === "sm" && "text-xs",984 size === "sm" && "text-xs",
985 size === "md" && "text-sm",985 size === "md" && "text-sm",
986 "group-data-[collapsible=icon]:hidden",986 "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...@@ -107,6 +107,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
107}107}
108108
109@theme inline {109@theme inline {
110 --color-zinc-25: oklch(99.2% 0 0);
110 --animate-skeleton: skeleton 2s -1s infinite linear;111 --animate-skeleton: skeleton 2s -1s infinite linear;
111 /* Duty-cycled indicator animations: long holds with stepped ramps, so the112 /* Duty-cycled indicator animations: long holds with stepped ramps, so the
112 compositor updates discrete frames instead of every vsync. */113 compositor updates discrete frames instead of every vsync. */
...@@ -143,6 +144,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -143,6 +144,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
143 --color-card: var(--card);144 --color-card: var(--card);
144 --color-foreground: var(--foreground);145 --color-foreground: var(--foreground);
145 --color-background: var(--background);146 --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);
146 --radius-sm: calc(var(--radius) - 4px);156 --radius-sm: calc(var(--radius) - 4px);
147 --radius-md: calc(var(--radius) - 2px);157 --radius-md: calc(var(--radius) - 2px);
148 --radius-lg: var(--radius);158 --radius-lg: var(--radius);
...@@ -313,7 +323,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -313,7 +323,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
313 }323 }
314324
315 .chat-composer-glass {325 .chat-composer-glass {
316 background: color-mix(in srgb, var(--card) 20%, transparent);326 background: var(--surface-raised);
317 }327 }
318328
319 /* Mix from --background (not --card): the lower chrome is a full-width329 /* 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...@@ -324,6 +334,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
324 background: color-mix(in srgb, var(--background) 20%, transparent);334 background: color-mix(in srgb, var(--background) 20%, transparent);
325 }335 }
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
327 .chat-composer-glass {345 .chat-composer-glass {
328 box-shadow:346 box-shadow:
329 0 18px 48px -20px rgb(0 0 0 / 28%),347 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...@@ -336,17 +354,19 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
336 }354 }
337355
338 .dark .chat-composer-glass {356 .dark .chat-composer-glass {
339 background: color-mix(in srgb, var(--card) 45%, transparent);357 background: var(--surface-raised);
340 }358 }
341359
342 .dark .chat-composer-lower-chrome {360 .dark .chat-composer-lower-chrome {
343 background: color-mix(in srgb, var(--background) 45%, transparent);361 background: color-mix(in srgb, var(--background) 45%, transparent);
344 }362 }
345363
364 .dark .chat-composer-lower-chrome[data-terminal-open="true"] {
365 background: var(--background);
366 }
367
346 .dark .chat-composer-glass {368 .dark .chat-composer-glass {
347 box-shadow:369 box-shadow: none;
348 0 18px 48px -20px rgb(0 0 0 / 60%),
349 0 4px 14px -7px rgb(0 0 0 / 40%);
350 }370 }
351371
352 .chat-composer-lower-chrome {372 .chat-composer-lower-chrome {
...@@ -410,24 +430,25 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -410,24 +430,25 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
410:root {430:root {
411 color-scheme: light;431 color-scheme: light;
412 --radius: 0.625rem;432 --radius: 0.625rem;
413 --background: var(--color-white);433 --background: var(--color-zinc-25);
414 --app-chrome-background: var(--background);434 --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);
416 --card: var(--color-white);437 --card: var(--color-white);
417 --card-foreground: var(--color-neutral-800);438 --card-foreground: var(--color-zinc-800);
418 --popover: var(--color-white);439 --popover: var(--color-white);
419 --popover-foreground: var(--color-neutral-800);440 --popover-foreground: var(--color-zinc-800);
420 --primary: oklch(0.488 0.217 264);441 --primary: oklch(0.488 0.217 264);
421 --primary-foreground: var(--color-white);442 --primary-foreground: var(--color-white);
422 --secondary: --alpha(var(--color-black) / 4%);443 --secondary: var(--color-zinc-50);
423 --secondary-foreground: var(--color-neutral-800);444 --secondary-foreground: var(--color-zinc-800);
424 --muted: --alpha(var(--color-black) / 4%);445 --muted: var(--color-zinc-50);
425 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));446 --muted-foreground: var(--color-zinc-500);
426 --accent: --alpha(var(--color-black) / 4%);447 --accent: var(--color-zinc-100);
427 --accent-foreground: var(--color-neutral-800);448 --accent-foreground: var(--color-zinc-900);
428 --destructive: var(--color-red-500);449 --destructive: var(--color-red-500);
429 --border: --alpha(var(--color-black) / 8%);450 --border: var(--color-zinc-200);
430 --input: --alpha(var(--color-black) / 10%);451 --input: var(--color-zinc-300);
431 --ring: oklch(0.488 0.217 264);452 --ring: oklch(0.488 0.217 264);
432 --destructive-foreground: var(--color-red-700);453 --destructive-foreground: var(--color-red-700);
433 --info: var(--color-blue-500);454 --info: var(--color-blue-500);
...@@ -436,24 +457,35 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -436,24 +457,35 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
436 --success-foreground: var(--color-emerald-700);457 --success-foreground: var(--color-emerald-700);
437 --warning: var(--color-amber-500);458 --warning: var(--color-amber-500);
438 --warning-foreground: var(--color-amber-700);459 --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
440 @variant dark {471 @variant dark {
441 color-scheme: dark;472 color-scheme: dark;
442 --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));473 --background: var(--color-zinc-950);
443 --app-chrome-background: var(--background);474 --app-chrome-background: var(--background);
444 --foreground: var(--color-neutral-100);475 --surface-raised: color-mix(in srgb, var(--card) 45%, transparent);
445 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));476 --foreground: var(--color-zinc-100);
446 --card-foreground: var(--color-neutral-100);477 --card: var(--color-zinc-900);
447 --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));478 --card-foreground: var(--color-zinc-100);
448 --popover-foreground: var(--color-neutral-100);479 --popover: var(--color-zinc-900);
480 --popover-foreground: var(--color-zinc-100);
449 --primary: oklch(0.588 0.217 264);481 --primary: oklch(0.588 0.217 264);
450 --primary-foreground: var(--color-white);482 --primary-foreground: var(--color-white);
451 --secondary: --alpha(var(--color-white) / 4%);483 --secondary: var(--color-zinc-900);
452 --secondary-foreground: var(--color-neutral-100);484 --secondary-foreground: var(--color-zinc-100);
453 --muted: --alpha(var(--color-white) / 4%);485 --muted: var(--color-zinc-900);
454 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));486 --muted-foreground: var(--color-zinc-400);
455 --accent: --alpha(var(--color-white) / 4%);487 --accent: var(--color-zinc-900);
456 --accent-foreground: var(--color-neutral-100);488 --accent-foreground: var(--color-zinc-100);
457 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));489 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
458 --border: --alpha(var(--color-white) / 6%);490 --border: --alpha(var(--color-white) / 6%);
459 --input: --alpha(var(--color-white) / 8%);491 --input: --alpha(var(--color-white) / 8%);
...@@ -468,39 +500,27 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -468,39 +500,27 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
468 }500 }
469}501}
470502
471/* Give the navigation rail its own cool graphite surface instead of borrowing503[data-app-sidebar] {
472 the workspace card color. The scoped semantic tokens keep sidebar v2's504 --sidebar: var(--color-zinc-50);
473 controls in the same palette without changing v1 or the settings nav. */505 --sidebar-foreground: var(--color-zinc-800);
474.app-sidebar {506 --sidebar-muted-foreground: var(--color-zinc-500);
475 --background: oklch(0.965 0.009 265);507 --sidebar-control-surface: var(--color-zinc-100);
476 --foreground: oklch(0.27 0.025 265);508 --sidebar-row-hover: var(--color-zinc-25);
477 --card: oklch(0.945 0.014 265);509 --sidebar-row-active: var(--color-white);
478 --card-foreground: var(--foreground);510 --sidebar-row-selected: var(--color-white);
479 --accent: oklch(0.875 0.008 265);511 --sidebar-border: var(--color-zinc-200);
480 --accent-foreground: oklch(0.24 0.012 265);512 --sidebar-stage-fade: var(--sidebar);
481 --muted: oklch(0.9 0.018 265);513}
482 --muted-foreground: oklch(0.49 0.03 265);514
483 --border: oklch(0.72 0.025 265 / 28%);515.dark [data-app-sidebar] {
484 --input: oklch(0.62 0.03 265 / 28%);516 --sidebar: var(--color-zinc-950);
485 --sidebar-stage-fade: var(--card);517 --sidebar-foreground: var(--color-zinc-100);
486 background-color: var(--card);518 --sidebar-muted-foreground: var(--color-zinc-400);
487}519 --sidebar-control-surface: var(--color-zinc-800);
488520 --sidebar-row-hover: var(--color-zinc-900);
489.dark .app-sidebar {521 --sidebar-row-active: var(--color-zinc-900);
490 --background: #000;522 --sidebar-row-selected: var(--color-zinc-900);
491 --foreground: #f1f3f7;523 --sidebar-border: var(--color-zinc-800);
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);
504}524}
505525
506body {526body {