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