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