diff --git a/README.md b/README.md index 1e9b0517945..e965916b9a7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) +- [Thread labels](./docs/user/thread-labels.md) - [Keyboard shortcuts](./docs/user/keybindings.md) - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index a209dbd7623..c4b81c4a167 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder, ThreadLabel } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import Constants from "expo-constants"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; @@ -9,6 +9,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; +import { THREAD_LABEL_OPTIONS } from "@t3tools/shared/threadLabels"; import { T3Wordmark } from "../../components/T3Wordmark"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../../lib/mobileBranding"; @@ -40,11 +41,13 @@ export function HomeHeader(props: { readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; + readonly selectedThreadLabel: ThreadLabel | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly onThreadLabelChange: (label: ThreadLabel | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenSettings: () => void; @@ -70,11 +73,13 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. + // key the "customized" icon state off the environment and label filters. const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + ? props.selectedEnvironmentId !== null || + props.selectedProjectKey !== null || + props.selectedThreadLabel !== null + : hasCustomHomeListOptions(props) || props.selectedThreadLabel !== null; const menuActions = useMemo( () => [ { @@ -113,6 +118,22 @@ function AndroidHomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), + { + id: "label", + title: "Label", + subactions: [ + { + id: "label:all", + title: "All labels", + state: checkedMenuState(props.selectedThreadLabel === null), + }, + ...THREAD_LABEL_OPTIONS.map((option) => ({ + id: `label:${option.value}`, + title: option.label, + state: checkedMenuState(props.selectedThreadLabel === option.value), + })), + ], + }, ...(threadListV2Enabled ? [] : ([ @@ -142,6 +163,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.projects, props.selectedEnvironmentId, props.selectedProjectKey, + props.selectedThreadLabel, props.threadSortOrder, threadListV2Enabled, ], @@ -178,6 +200,18 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id === "label:all") { + props.onThreadLabelChange(null); + return; + } + if (id.startsWith("label:")) { + const label = THREAD_LABEL_OPTIONS.find( + (option) => option.value === id.slice("label:".length), + ); + if (label) props.onThreadLabelChange(label.value); + return; + } + const projectSort = PROJECT_SORT_OPTIONS.find( (option) => id === `project-sort:${option.value}`, ); @@ -292,11 +326,13 @@ function IosHomeHeader(props: HomeHeaderProps) { const iconColor = useThemeColor("--color-icon"); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. + // key the "customized" icon state off the environment and label filters. const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + ? props.selectedEnvironmentId !== null || + props.selectedProjectKey !== null || + props.selectedThreadLabel !== null + : hasCustomHomeListOptions(props) || props.selectedThreadLabel !== null; const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; @@ -421,6 +457,26 @@ function IosHomeHeader(props: HomeHeaderProps) { ) : null} + + Label + props.onThreadLabelChange(null)} + subtitle="Show threads with any label" + > + All labels + + {THREAD_LABEL_OPTIONS.map((option) => ( + props.onThreadLabelChange(option.value)} + > + {option.label} + + ))} + + {threadListV2Enabled ? null : ( Sort projects diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 16a3efde6dd..b4ee89617cd 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,6 +1,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; +import type { ThreadLabel } from "@t3tools/contracts"; import { useEffect, useMemo, useState } from "react"; import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; @@ -45,6 +46,7 @@ export function HomeRouteScreen() { pinThread, unpinThread, unsettleThread, + setThreadLabel, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -76,6 +78,7 @@ export function HomeRouteScreen() { } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const [selectedThreadLabel, setSelectedThreadLabel] = useState(null); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ @@ -134,10 +137,12 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} + selectedThreadLabel={selectedThreadLabel} projectSortOrder={listOptions.projectSortOrder} threadSortOrder={listOptions.threadSortOrder} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} + onThreadLabelChange={setSelectedThreadLabel} onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} @@ -159,6 +164,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadLabel={setThreadLabel} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => @@ -197,6 +203,7 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} + selectedThreadLabel={selectedThreadLabel} threads={threads} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ba32dc6b609..ae5bf11d1ba 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -15,6 +15,7 @@ import type { EnvironmentId, SidebarProjectGroupingMode, SidebarThreadSortOrder, + ThreadLabel, } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -88,6 +89,7 @@ interface HomeScreenProps { readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; + readonly selectedThreadLabel: ThreadLabel | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; @@ -113,6 +115,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSetThreadLabel: ( + thread: EnvironmentThreadShell, + label: ThreadLabel | null, + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -344,23 +350,26 @@ export function HomeScreen(props: HomeScreenProps) { ); const scopedThreads = useMemo( () => - selectedProjectRefKeys === null - ? props.threads - : props.threads.filter((thread) => - selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [props.threads, selectedProjectRefKeys], + props.threads.filter( + (thread) => + (props.selectedThreadLabel === null || thread.label === props.selectedThreadLabel) && + (selectedProjectRefKeys === null || + selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId))), + ), + [props.selectedThreadLabel, props.threads, selectedProjectRefKeys], ); const scopedPendingTasks = useMemo( () => - selectedProjectRefKeys === null - ? props.pendingTasks - : props.pendingTasks.filter((pendingTask) => - selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + props.selectedThreadLabel !== null + ? [] + : selectedProjectRefKeys === null + ? props.pendingTasks + : props.pendingTasks.filter((pendingTask) => + selectedProjectRefKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), ), - ), - [props.pendingTasks, selectedProjectRefKeys], + [props.pendingTasks, props.selectedThreadLabel, selectedProjectRefKeys], ); const projectGroups = useMemo( @@ -537,7 +546,7 @@ export function HomeScreen(props: HomeScreenProps) { const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); - const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}:${props.selectedThreadLabel ?? "all"}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -598,6 +607,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const labelsEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadLabels === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -612,7 +630,7 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ - threads: props.threads.filter((thread) => thread.archivedAt === null), + threads: scopedThreads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, @@ -638,7 +656,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, props.searchQuery, props.selectedEnvironmentId, - props.threads, + scopedThreads, matchedThreadKeys, threadListV2Enabled, v2ScopedProjectGroup, @@ -664,18 +682,29 @@ export function HomeScreen(props: HomeScreenProps) { const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = useMemo( () => - props.pendingTasks.filter( - (pendingTask) => - (props.selectedEnvironmentId === null || - pendingTask.message.environmentId === props.selectedEnvironmentId) && - (v2ScopedProjectKeys === null || - v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), - )) && - (v2SearchQuery.length === 0 || - pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ), - [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery], + props.selectedThreadLabel !== null + ? [] + : props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProjectKeys === null || + v2ScopedProjectKeys.has( + scopedProjectKey( + pendingTask.message.environmentId, + pendingTask.creation.projectId, + ), + )) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ), + [ + props.pendingTasks, + props.selectedEnvironmentId, + props.selectedThreadLabel, + v2ScopedProjectKeys, + v2SearchQuery, + ], ); const threadListV2Items = useMemo( () => @@ -784,6 +813,8 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + labelsSupported={labelsEnvironmentIds.has(thread.environmentId)} + onSetThreadLabel={props.onSetThreadLabel} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} @@ -810,12 +841,14 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + labelsEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, props.onSelectPendingTask, props.onSelectThread, + props.onSetThreadLabel, props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, @@ -844,6 +877,8 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery: props.searchQuery, snoozePresetMinute: nowMinute, threadSearchMatchByKey, + labelsEnvironmentIds, + onSetThreadLabel: props.onSetThreadLabel, }), [ projectByKey, @@ -851,7 +886,9 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, + labelsEnvironmentIds, nowMinute, + props.onSetThreadLabel, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], @@ -863,8 +900,17 @@ export function HomeScreen(props: HomeScreenProps) { savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, threadSearchMatchByKey, + labelsEnvironmentIds, + onSetThreadLabel: props.onSetThreadLabel, }), - [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], + [ + labelsEnvironmentIds, + projectCwdByKey, + props.onSetThreadLabel, + props.savedConnectionsById, + props.searchQuery, + threadSearchMatchByKey, + ], ); const renderItem = useCallback( @@ -927,6 +973,8 @@ export function HomeScreen(props: HomeScreenProps) { onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} onSelectThread={props.onSelectThread} + labelsSupported={labelsEnvironmentIds.has(thread.environmentId)} + onSetThreadLabel={props.onSetThreadLabel} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> @@ -954,9 +1002,11 @@ export function HomeScreen(props: HomeScreenProps) { props.onNewThreadInProject, props.onSelectPendingTask, props.onSelectThread, + props.onSetThreadLabel, props.searchQuery, props.savedConnectionsById, threadSearchMatchByKey, + labelsEnvironmentIds, updateGroupDisplay, ], ); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 99e3cb36c07..00931e729f0 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -5,6 +5,7 @@ import { buildHomeListFilterMenu } from "./home-list-filter-menu"; describe("buildHomeListFilterMenu", () => { it("adds a project scope submenu that selects and clears the same scope as the chips", () => { const onProjectChange = vi.fn(); + const onThreadLabelChange = vi.fn(); const menu = buildHomeListFilterMenu({ environments: [], projects: [ @@ -13,10 +14,12 @@ describe("buildHomeListFilterMenu", () => { ], selectedEnvironmentId: null, selectedProjectKey: "environment-1:project-1", + selectedThreadLabel: null, projectSortOrder: "updated_at", threadSortOrder: "updated_at", onEnvironmentChange: vi.fn(), onProjectChange, + onThreadLabelChange, onProjectSortOrderChange: vi.fn(), onThreadSortOrderChange: vi.fn(), }); @@ -39,5 +42,20 @@ describe("buildHomeListFilterMenu", () => { projectMenu.items[2]?.onPress(); expect(onProjectChange).toHaveBeenNthCalledWith(1, null); expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); + + const labelMenu = menu.items.find((item) => item.type === "submenu" && item.title === "Label"); + expect(labelMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "All labels", state: "on" }, + { title: "Bug", state: "off" }, + { title: "Feature", state: "off" }, + { title: "Review", state: "off" }, + { title: "New Build", state: "off" }, + ], + }); + if (labelMenu?.type !== "submenu") throw new Error("Expected label submenu"); + labelMenu.items[1]?.onPress(); + expect(onThreadLabelChange).toHaveBeenCalledWith("bug"); }); }); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index edd0176f862..43c05c2c12b 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,4 +1,5 @@ -import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder, ThreadLabel } from "@t3tools/contracts"; +import { THREAD_LABEL_OPTIONS } from "@t3tools/shared/threadLabels"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; @@ -37,10 +38,12 @@ export function buildHomeListFilterMenu(props: { readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; + readonly selectedThreadLabel: ThreadLabel | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly onThreadLabelChange: (label: ThreadLabel | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; /** False hides the sort/group submenus. Thread List v2 uses a fixed @@ -95,6 +98,26 @@ export function buildHomeListFilterMenu(props: { }); } + items.push({ + type: "submenu", + title: "Label", + items: [ + { + type: "action", + title: "All labels", + subtitle: "Show threads with any label", + state: props.selectedThreadLabel === null ? "on" : "off", + onPress: () => props.onThreadLabelChange(null), + }, + ...THREAD_LABEL_OPTIONS.map((option) => ({ + type: "action" as const, + title: option.label, + state: props.selectedThreadLabel === option.value ? ("on" as const) : ("off" as const), + onPress: () => props.onThreadLabelChange(option.value), + })), + ], + }); + if (props.listOrganization !== false) { items.push( { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b..386dc837557 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import type { ThreadLabel } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -36,6 +37,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsLabels(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadLabels === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -211,12 +219,19 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly setThreadLabel: ( + thread: EnvironmentThreadShell, + label: ThreadLabel | null, + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); + const updateThreadMetadataMutation = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( @@ -378,6 +393,35 @@ export function useThreadListActions(): { [unpinMutation], ); + const setThreadLabel = useCallback( + async (thread: EnvironmentThreadShell, label: ThreadLabel | null) => { + if (!environmentSupportsLabels(thread.environmentId)) { + Alert.alert( + "Could not update label", + "This environment's server does not support thread labels yet. Update the server to use labels.", + ); + return false; + } + selectionHaptic(); + const result = await updateThreadMetadataMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, label }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not update label", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread label could not be updated.", + ); + return false; + } + return true; + }, + [updateThreadMetadataMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -389,6 +433,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + setThreadLabel, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 322ac60759d..89fbb1e24d6 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -10,7 +10,8 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadLabel } from "@t3tools/contracts"; +import { THREAD_LABEL_OPTIONS } from "@t3tools/shared/threadLabels"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; @@ -207,6 +208,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + setThreadLabel, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -257,6 +259,7 @@ function ThreadNavigationSidebarPane( [threadSearch.matches], ); const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const [selectedThreadLabel, setSelectedThreadLabel] = useState(null); const projectScopes = useMemo( () => buildHomeProjectScopes({ @@ -326,23 +329,26 @@ function ThreadNavigationSidebarPane( ); const scopedThreads = useMemo( () => - selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [selectedProjectRefs, threads], + threads.filter( + (thread) => + (selectedThreadLabel === null || thread.label === selectedThreadLabel) && + (selectedProjectRefs === null || + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId))), + ), + [selectedProjectRefs, selectedThreadLabel, threads], ); const scopedPendingTasks = useMemo( () => - selectedProjectRefs === null - ? pendingTasks - : pendingTasks.filter((pendingTask) => - selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + selectedThreadLabel !== null + ? [] + : selectedProjectRefs === null + ? pendingTasks + : pendingTasks.filter((pendingTask) => + selectedProjectRefs.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), ), - ), - [pendingTasks, selectedProjectRefs], + [pendingTasks, selectedProjectRefs, selectedThreadLabel], ); const groups = useMemo( () => @@ -431,7 +437,7 @@ function ThreadNavigationSidebarPane( const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); - const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; + const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${selectedThreadLabel ?? "all"}:${props.searchQuery.trim()}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -492,6 +498,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const labelsEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadLabels === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -504,7 +519,7 @@ function ThreadNavigationSidebarPane( nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ - threads: threads.filter((thread) => thread.archivedAt === null), + threads: scopedThreads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, @@ -533,7 +548,7 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, - threads, + scopedThreads, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -558,17 +573,23 @@ function ThreadNavigationSidebarPane( // deletable while their environment is offline. Same environment scope // and search filter as the list. const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); - const v2PendingTasks = pendingTasks.filter( - (pendingTask) => - (options.selectedEnvironmentId === null || - pendingTask.message.environmentId === options.selectedEnvironmentId) && - (selectedProjectRefs === null || - selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), - )) && - (v2SearchQuery.length === 0 || - pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ); + const v2PendingTasks = + selectedThreadLabel !== null + ? [] + : pendingTasks.filter( + (pendingTask) => + (options.selectedEnvironmentId === null || + pendingTask.message.environmentId === options.selectedEnvironmentId) && + (selectedProjectRefs === null || + selectedProjectRefs.has( + scopedProjectKey( + pendingTask.message.environmentId, + pendingTask.creation.projectId, + ), + )) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, @@ -595,6 +616,7 @@ function ThreadNavigationSidebarPane( pendingTasks, props.searchQuery, selectedProjectRefs, + selectedThreadLabel, settledShelfExpanded, snoozedShelfExpanded, threadListV2Enabled, @@ -644,6 +666,23 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), + { + id: "label", + title: "Label", + subactions: [ + { + id: "label:all", + title: "All labels", + subtitle: "Show threads with any label", + state: selectedThreadLabel === null ? "on" : "off", + }, + ...THREAD_LABEL_OPTIONS.map((option) => ({ + id: `label:${option.value}`, + title: option.label, + state: selectedThreadLabel === option.value ? ("on" as const) : ("off" as const), + })), + ], + }, // v2 lays the list out in fixed creation order — offering sort/group // controls it silently ignores would be a lie. Environment still // scopes the v2 partition, so it stays. @@ -670,7 +709,14 @@ function ThreadNavigationSidebarPane( }, ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [ + environments, + options, + projectFilterOptions, + selectedProjectKey, + selectedThreadLabel, + threadListV2Enabled, + ], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -697,6 +743,17 @@ function ThreadNavigationSidebarPane( } return; } + if (event === "label:all") { + setSelectedThreadLabel(null); + return; + } + if (event.startsWith("label:")) { + const label = THREAD_LABEL_OPTIONS.find( + (option) => option.value === event.slice("label:".length), + ); + if (label) setSelectedThreadLabel(label.value); + return; + } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); @@ -717,6 +774,7 @@ function ThreadNavigationSidebarPane( projectFilterOptions, setProjectSortOrder, setSelectedEnvironmentId, + setSelectedThreadLabel, setThreadSortOrder, ], ); @@ -776,6 +834,7 @@ function ThreadNavigationSidebarPane( const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", + selectedThreadLabel: selectedThreadLabel ?? "", projectByKey, projectCwdByKey, projectTitleByProjectKey, @@ -786,6 +845,7 @@ function ThreadNavigationSidebarPane( }), [ props.selectedThreadKey, + selectedThreadLabel, projectByKey, projectCwdByKey, projectTitleByProjectKey, @@ -929,11 +989,13 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + labelsSupported={labelsEnvironmentIds.has(thread.environmentId)} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadLabel={setThreadLabel} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1034,6 +1096,8 @@ function ThreadNavigationSidebarPane( onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} onSelectThread={handleSelectThread} + labelsSupported={labelsEnvironmentIds.has(thread.environmentId)} + onSetThreadLabel={setThreadLabel} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} @@ -1063,6 +1127,7 @@ function ThreadNavigationSidebarPane( openPendingTask, pinThread, pinningEnvironmentIds, + labelsEnvironmentIds, projectByKey, projectCwdByKey, projectTitleByProjectKey, @@ -1085,14 +1150,17 @@ function ThreadNavigationSidebarPane( unpinThread, unsettleThread, unsnoozeThread, + setThreadLabel, updateGroupDisplay, ], ); - // v2 ignores the sort/group options, so only the environment filter can - // light the "customized" state while the beta is on. + // v2 ignores the sort/group options, so only the environment and label + // filters can light the "customized" state while the beta is on. const filterCustomized = threadListV2Enabled - ? options.selectedEnvironmentId !== null || selectedProjectKey !== null - : hasCustomHomeListOptions({ ...options, selectedProjectKey }); + ? options.selectedEnvironmentId !== null || + selectedProjectKey !== null || + selectedThreadLabel !== null + : hasCustomHomeListOptions({ ...options, selectedProjectKey }) || selectedThreadLabel !== null; const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; @@ -1103,10 +1171,12 @@ function ThreadNavigationSidebarPane( projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, selectedProjectKey, + selectedThreadLabel, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, + onThreadLabelChange: setSelectedThreadLabel, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, listOrganization: !threadListV2Enabled, @@ -1116,8 +1186,10 @@ function ThreadNavigationSidebarPane( options, projectFilterOptions, selectedProjectKey, + selectedThreadLabel, setProjectSortOrder, setSelectedEnvironmentId, + setSelectedThreadLabel, setThreadSortOrder, threadListV2Enabled, ], diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 855713946ff..c1b7fce4105 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -5,6 +5,8 @@ import type { } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { MenuAction } from "@react-native-menu/menu"; +import type { ThreadLabel } from "@t3tools/contracts"; +import { THREAD_LABEL_OPTIONS, threadLabelDisplayName } from "@t3tools/shared/threadLabels"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; @@ -414,6 +416,26 @@ const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +function ThreadLabelPill(props: { readonly label: ThreadLabel; readonly selected?: boolean }) { + return ( + + + {threadLabelDisplayName(props.label)} + + + ); +} + export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; readonly thread: EnvironmentThreadShell; @@ -429,6 +451,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSetThreadLabel: ( + thread: EnvironmentThreadShell, + label: ThreadLabel | null, + ) => Promise; + readonly labelsSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< @@ -470,6 +497,32 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleSetLabel = useCallback( + (label: ThreadLabel | null) => { + void props.onSetThreadLabel(thread, label); + }, + [props.onSetThreadLabel, thread], + ); + const labelMenuActions = useMemo( + () => + props.labelsSupported + ? [ + { + id: "label", + title: thread.label ? `Label: ${threadLabelDisplayName(thread.label)}` : "Add label", + image: "tag", + subactions: [ + ...THREAD_LABEL_OPTIONS.map((option) => ({ + id: `label:${option.value}`, + title: option.label, + })), + ...(thread.label ? [{ id: "label:clear", title: "Clear label" }] : []), + ], + }, + ] + : [], + [props.labelsSupported, thread.label], + ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, @@ -483,8 +536,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); + if (nativeEvent.event.startsWith("label:")) { + const selectedLabel = nativeEvent.event.slice("label:".length); + const option = THREAD_LABEL_OPTIONS.find((candidate) => candidate.value === selectedLabel); + handleSetLabel(selectedLabel === "clear" ? null : (option?.value ?? null)); + } }, - [handleArchive, handleDelete], + [handleArchive, handleDelete, handleSetLabel], ); const statusPill = effectiveStatus ? ( @@ -560,9 +618,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { }} > - - {thread.title} - + + + {thread.title} + + {thread.label ? : null} + {statusPill} {timestamp} @@ -613,15 +674,18 @@ export const ThreadListRow = memo(function ThreadListRow(props: { > - - {thread.title} - + + + {thread.title} + + {thread.label ? : null} + {statusPill} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 24f7166916b..9fead04fd23 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -5,6 +5,8 @@ import type { import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; +import type { ThreadLabel } from "@t3tools/contracts"; +import { THREAD_LABEL_OPTIONS, threadLabelDisplayName } from "@t3tools/shared/threadLabels"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, @@ -91,6 +93,26 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ /** Rounded-row radius shared with the v1 sidebar rows. */ const SIDEBAR_V2_ROW_RADIUS = 12; +function ThreadLabelPill(props: { readonly label: ThreadLabel; readonly selected?: boolean }) { + return ( + + + {threadLabelDisplayName(props.label)} + + + ); +} + /** Section label + rule: the only structure in an otherwise flat list. */ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { readonly label: string; @@ -347,6 +369,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; + readonly onSetThreadLabel: ( + thread: EnvironmentThreadShell, + label: ThreadLabel | null, + ) => Promise; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -354,6 +380,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread label metadata. */ + readonly labelsSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -382,6 +410,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onSetThreadLabel, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -417,6 +446,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleSetLabel = useCallback( + (label: ThreadLabel | null) => { + void onSetThreadLabel(thread, label); + }, + [onSetThreadLabel, thread], + ); // Swipe: the v2 primary action is the lifecycle transition. Every settled // row can un-settle — explicit settles clear the override, auto-settled @@ -484,6 +519,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)], [pinMenuItem], ); + const labelMenuActions = useMemo( + () => + props.labelsSupported + ? [ + { + id: "label", + title: thread.label ? `Label: ${threadLabelDisplayName(thread.label)}` : "Add label", + image: "tag", + subactions: [ + ...THREAD_LABEL_OPTIONS.map((option) => ({ + id: `label:${option.value}`, + title: option.label, + })), + ...(thread.label ? [{ id: "label:clear", title: "Clear label" }] : []), + ], + }, + ] + : [], + [props.labelsSupported, thread.label], + ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "settle") handleSettle(); @@ -493,6 +548,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); + if (nativeEvent.event.startsWith("label:")) { + const selectedLabel = nativeEvent.event.slice("label:".length); + const option = THREAD_LABEL_OPTIONS.find((candidate) => candidate.value === selectedLabel); + handleSetLabel(selectedLabel === "clear" ? null : (option?.value ?? null)); + } const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ event: nativeEvent.event, displayedPresets: snoozePresets, @@ -509,6 +569,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleDelete, handlePin, handleSettle, + handleSetLabel, handleSnooze, handleUnpin, handleUnsettle, @@ -613,15 +674,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel?.label ?? timeLabel} - - {thread.title} - + + + {thread.title} + + {thread.label ? : null} + {props.searchMatch ? ( ) : null} - - {thread.title} - + + + {thread.title} + + {thread.label ? : null} + {props.searchMatch ? ( {(close) => ( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e66..d4a9ac4f429 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, threadPinning: true, + threadLabels: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fe683b08a3c..161fda28c33 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + label: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -771,6 +772,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.label !== undefined ? { label: event.payload.label } : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b7b630a16fd..c7d03b56814 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -84,6 +84,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + label, created_at, updated_at, deleted_at @@ -102,6 +103,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 0, 0, + 'bug', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -315,6 +317,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + label: "bug", titleRegeneration: null, deletedAt: null, messages: [ @@ -431,6 +434,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + label: "bug", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 2d8a98d8c6f..1dd7d14c459 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -387,6 +387,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + label, title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -422,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + label, title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -459,6 +461,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + label, title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -896,6 +899,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + label, title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1335,6 +1339,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + label: row.label ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -1539,6 +1544,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + label: row.label ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -1674,6 +1680,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + label: row.label ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -1817,6 +1824,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + label: row.label ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2092,6 +2100,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + label: threadRow.value.label ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2195,6 +2204,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + label: threadRow.value.label ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f355bfc45ae..5d028553bee 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -663,7 +663,9 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Please investigate reconnect failures after restar..."; - harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Generated title", label: "feature" }), + ); await Effect.runPromise( harness.engine.dispatch({ @@ -707,13 +709,62 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Generated title"); + expect(thread?.label).toBe("feature"); + }); + + it("preserves a manual label when generating a first-turn title", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const seededTitle = "Please investigate reconnect failures after restar..."; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Generated title", label: "feature" }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-seed-manual-label"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + label: "bug", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-title-manual-label"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-title-manual-label"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "Generated title" + ); + }); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.label).toBe("bug"); }); it("regenerates a thread title from the current conversation", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ title: "Resolve stale reconnect state" }), + Effect.succeed({ title: "Resolve stale reconnect state", label: "bug" }), ); await harness.runEffect( @@ -785,6 +836,56 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Resolve stale reconnect state"); + expect(thread?.label).toBe("bug"); + expect(thread?.titleRegeneration).toBeNull(); + }); + + it("applies a regenerated label when the generated title is unchanged", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Keep meaningful title", label: "feature" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-before-unchanged-regeneration"), + threadId: ThreadId.make("thread-1"), + title: "Keep meaningful title", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-unchanged-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-unchanged-regeneration"), + role: "user", + text: "Investigate the reconnect state.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-unchanged-regeneration"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("Keep meaningful title"); + expect(thread?.label).toBe("feature"); expect(thread?.titleRegeneration).toBeNull(); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179..855fc6f7f67 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,6 +3,7 @@ import { CommandId, EventId, type ModelSelection, + type ThreadLabel, type OrchestrationEvent, ProviderDriverKind, type ProjectId, @@ -885,6 +886,9 @@ const make = Effect.gen(function* () { commandId: yield* serverCommandId("thread-title-rename"), threadId: input.threadId, title: generated.title, + ...(thread.label == null && generated.label !== undefined + ? { label: generated.label, expectedLabel: thread.label ?? null } + : {}), }); }).pipe( Effect.catchCause((cause) => @@ -913,7 +917,7 @@ const make = Effect.gen(function* () { const { message, attachments } = formatThreadTitleContext(thread.messages); if (message.length === 0) { - return { _tag: "Completed", title: undefined } as const; + return { _tag: "Completed", title: undefined, label: undefined } as const; } const previousTitle = event.payload.previousTitle ?? thread.title; @@ -935,10 +939,6 @@ const make = Effect.gen(function* () { ...(attachments.length > 0 ? { attachments } : {}), modelSelection, }); - if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) { - return { _tag: "Completed", title: undefined } as const; - } - const latestThread = yield* resolveThread(event.payload.threadId); if ( !latestThread || @@ -947,8 +947,19 @@ const make = Effect.gen(function* () { ) { return { _tag: "Superseded" } as const; } + if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) { + return { + _tag: "Completed", + title: undefined, + label: latestThread.label == null ? generated.label : undefined, + } as const; + } - return { _tag: "Completed", title: generated.title } as const; + return { + _tag: "Completed", + title: generated.title, + label: latestThread.label == null ? generated.label : undefined, + } as const; }); const dispatchThreadTitleRegenerationCompletion = Effect.fn( "dispatchThreadTitleRegenerationCompletion", @@ -956,6 +967,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly requestId: CommandId; readonly title?: string; + readonly label?: ThreadLabel; }) { yield* orchestrationEngine.dispatch({ type: "thread.title.regeneration.complete", @@ -963,6 +975,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.label !== undefined ? { label: input.label } : {}), }); }); const findInterruptedThreadTitleRegenerations = Effect.fn( @@ -1021,7 +1034,7 @@ const make = Effect.gen(function* () { return Effect.logWarning("provider command reactor failed to regenerate thread title", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)); + }).pipe(Effect.as({ _tag: "Completed", title: undefined, label: undefined } as const)); }), ); if (result._tag === "Superseded") { @@ -1032,6 +1045,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, requestId, ...(result.title !== undefined ? { title: result.title } : {}), + ...(result.label !== undefined ? { label: result.label } : {}), }; yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( Effect.catchCause((cause) => { diff --git a/apps/server/src/orchestration/decider.labels.test.ts b/apps/server/src/orchestration/decider.labels.test.ts new file mode 100644 index 00000000000..9d584052a40 --- /dev/null +++ b/apps/server/src/orchestration/decider.labels.test.ts @@ -0,0 +1,106 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type ThreadLabel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function makeReadModel(label: ThreadLabel | null): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + label, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("thread label decider", (it) => { + it.effect("sets and clears labels through thread metadata", () => + Effect.gen(function* () { + const setEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-label-set"), + threadId: ThreadId.make("thread-1"), + label: "bug", + }, + readModel: makeReadModel(null), + }); + const setEvents = Array.isArray(setEvent) ? setEvent : [setEvent]; + expect(setEvents).toHaveLength(1); + expect(setEvents[0]?.type).toBe("thread.meta-updated"); + if (setEvents[0]?.type !== "thread.meta-updated") return; + expect(setEvents[0].payload.label).toBe("bug"); + + const clearEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-label-clear"), + threadId: ThreadId.make("thread-1"), + label: null, + }, + readModel: makeReadModel("bug"), + }); + const clearEvents = Array.isArray(clearEvent) ? clearEvent : [clearEvent]; + expect(clearEvents[0]?.type).toBe("thread.meta-updated"); + if (clearEvents[0]?.type === "thread.meta-updated") { + expect(clearEvents[0].payload.label).toBeNull(); + } + }), + ); + + it.effect("drops a stale compare-and-set label update", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-label-set"), + threadId: ThreadId.make("thread-1"), + label: "feature", + expectedLabel: null, + }, + readModel: makeReadModel("bug"), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.meta-updated"); + if (events[0]?.type !== "thread.meta-updated") return; + expect(events[0].payload.label).toBeUndefined(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93d..e9359dedd74 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -757,6 +757,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.branch !== command.expectedBranch ? thread.branch : command.branch; + const label = + command.label !== undefined && + command.expectedLabel !== undefined && + (thread.label ?? null) !== command.expectedLabel + ? undefined + : command.label; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -787,6 +793,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(label !== undefined ? { label } : {}), updatedAt: occurredAt, }, }; @@ -811,6 +818,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), + ...(requestIsCurrent && command.label !== undefined && thread.label == null + ? { label: command.label } + : {}), ...(requestIsCurrent ? { titleRegeneration: null } : {}), updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt, }, diff --git a/apps/server/src/orchestration/projector.labels.test.ts b/apps/server/src/orchestration/projector.labels.test.ts new file mode 100644 index 00000000000..2c7602ec13e --- /dev/null +++ b/apps/server/src/orchestration/projector.labels.test.ts @@ -0,0 +1,85 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects thread labels and clears them", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.label ?? null).toBeNull(); + + const labeled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + label: "review", + updatedAt: now, + }, + }), + ); + expect(labeled.threads[0]?.label).toBe("review"); + + const cleared = yield* projectEvent( + labeled, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + label: null, + updatedAt: now, + }, + }), + ); + expect(cleared.threads[0]?.label).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f9..1974d789213 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -296,6 +296,7 @@ export function projectEvent( settledAt: null, snoozedUntil: null, snoozedAt: null, + label: null, deletedAt: null, messages: [], activities: [], @@ -432,6 +433,7 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.label !== undefined ? { label: payload.label } : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 71d7df566fd..33b16f7481a 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -159,6 +159,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", + label: "bug", latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -178,6 +179,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.label, "bug"); // Un-settle to the keep-active pin and wake the snooze; confirm the // flips persist. @@ -188,6 +190,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + label: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -198,6 +201,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); assert.strictEqual(updated?.pinnedAt, null); + assert.strictEqual(updated?.label, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0e2adeecf3b..488f80743ca 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + label, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.label ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + label = excluded.label, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + label, title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + label, title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1309cd7ef59..0da6c119aac 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -49,6 +49,7 @@ import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; +import Migration0037 from "./Migrations/037_ProjectionThreadsLabels.ts"; /** * Migration loader with all migrations defined inline. @@ -97,6 +98,7 @@ export const migrationEntries = [ [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], + [37, "ProjectionThreadsLabels", Migration0037], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/037_ProjectionThreadsLabels.test.ts b/apps/server/src/persistence/Migrations/037_ProjectionThreadsLabels.test.ts new file mode 100644 index 00000000000..83377dfa85c --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_ProjectionThreadsLabels.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("037_ProjectionThreadsLabels", (it) => { + it.effect("adds the nullable thread label column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 36 }); + yield* runMigrations({ toMigrationInclusive: 37 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "label")); + + yield* runMigrations({ toMigrationInclusive: 37 }); + const repeatedColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.equal(repeatedColumns.filter((column) => column.name === "label").length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/037_ProjectionThreadsLabels.ts b/apps/server/src/persistence/Migrations/037_ProjectionThreadsLabels.ts new file mode 100644 index 00000000000..753785ca9eb --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_ProjectionThreadsLabels.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN label TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a0cee8e3298..24666009f7f 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadLabel, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -42,6 +43,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + label: Schema.optional(Schema.NullOr(ThreadLabel)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index aa3e59e2bf2..3b8331e3ab6 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -28,6 +28,7 @@ import { import { normalizeCliError, sanitizeCommitSubject, + sanitizeThreadLabel, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, @@ -354,8 +355,10 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu modelSelection: input.modelSelection, }); + const label = sanitizeThreadLabel(generated.label); return { title: sanitizeThreadTitle(generated.title), + ...(label !== undefined ? { label } : {}), }; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0b870ac1d67..57474220226 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -30,6 +30,7 @@ import { import { normalizeCliError, sanitizeCommitSubject, + sanitizeThreadLabel, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, @@ -400,8 +401,10 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func modelSelection: input.modelSelection, }); + const label = sanitizeThreadLabel(generated.label); return { title: sanitizeThreadTitle(generated.title), + ...(label !== undefined ? { label } : {}), } satisfies TextGeneration.ThreadTitleGenerationResult; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 5ae057b54f6..c6508c53d5c 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -19,6 +19,7 @@ import { } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, + sanitizeThreadLabel, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -254,8 +255,10 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu modelSelection: input.modelSelection, }); + const label = sanitizeThreadLabel(generated.label); return { title: sanitizeThreadTitle(generated.title), + ...(label !== undefined ? { label } : {}), } satisfies TextGeneration.ThreadTitleGenerationResult; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1cf3d13e225..94721a3c958 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -20,6 +20,7 @@ import { } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, + sanitizeThreadLabel, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -246,8 +247,10 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi modelSelection: input.modelSelection, }); + const label = sanitizeThreadLabel(generated.label); return { title: sanitizeThreadTitle(generated.title), + ...(label !== undefined ? { label } : {}), } satisfies TextGeneration.ThreadTitleGenerationResult; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cff..ce98d844310 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -27,6 +27,7 @@ import { import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, + sanitizeThreadLabel, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -610,8 +611,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" attachments: input.attachments, }); + const label = sanitizeThreadLabel(generated.label); return { title: sanitizeThreadTitle(generated.title), + ...(label !== undefined ? { label } : {}), }; }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f..471b1d6f13b 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,12 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { + ChatAttachment, + ModelSelection, + ProviderInstanceId, + ThreadLabel, +} from "@t3tools/contracts"; import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; @@ -71,6 +76,8 @@ export interface ThreadTitleGenerationInput { export interface ThreadTitleGenerationResult { title: string; + /** AI classification used to organize the thread when no label was set manually. */ + label?: ThreadLabel | undefined; } export interface TextGenerationService { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 7614cc9e00f..93ca7f56e2c 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -6,7 +6,11 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { + normalizeCliError, + sanitizeThreadLabel, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { @@ -157,6 +161,10 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain( "Generate a title that will help the user recognize this T3 Code thread weeks later.", ); + expect(result.prompt).toContain("Return JSON with exactly two keys: title and label."); + expect(result.prompt).toContain( + "- new-build: creating a new application, product, or system from scratch.", + ); expect(result.prompt).toContain( "Title the subject and outcome. Discard incidental instructions.", ); @@ -246,6 +254,23 @@ describe("sanitizeThreadTitle", () => { }); }); +describe("sanitizeThreadLabel", () => { + it.each([ + ["bug", "bug"], + ["Feature", "feature"], + ["review", "review"], + ["New Build", "new-build"], + ["new_build", "new-build"], + ] as const)("normalizes %s", (raw, expected) => { + expect(sanitizeThreadLabel(raw)).toBe(expected); + }); + + it("rejects labels outside the built-in set", () => { + expect(sanitizeThreadLabel("question")).toBeUndefined(); + expect(sanitizeThreadLabel(null)).toBeUndefined(); + }); +}); + describe("normalizeCliError", () => { it("detects 'Command not found' and includes CLI name in the message", () => { const error = normalizeCliError( diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 5eaef8c36ce..7267e8dd7d5 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -214,10 +214,18 @@ export interface ThreadTitlePromptInput { policy?: TextGenerationPolicy | undefined; } +const THREAD_TITLE_LABEL_INSTRUCTIONS = `Choose the label from the durable subject and desired outcome: +- bug: fixing broken, failing, or regressed behavior. +- feature: changing or extending an existing product. +- review: inspecting or auditing existing code, behavior, or a proposed change. +- new-build: creating a new application, product, or system from scratch. +Use the canonical label value, not its display name.`; + // Keep shared editorial rules in these two prompts in sync. Regeneration // intentionally adds guidance for thread history and the previous title. const INITIAL_THREAD_TITLE_PROMPT = `Generate a title that will help the user recognize this T3 Code thread weeks later. -Return JSON with exactly one key: title. +Return JSON with exactly two keys: title and label. +${THREAD_TITLE_LABEL_INSTRUCTIONS} Before answering, silently reduce the request to: - Subject: What system, feature, or problem is this really about? @@ -243,7 +251,8 @@ Editorial rules: function regenerateThreadTitlePrompt(previousTitle: string): string { return `Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later. The previous title was ${JSON.stringify(previousTitle)}. -Return JSON with exactly one key: title. +Return JSON with exactly two keys: title and label. +${THREAD_TITLE_LABEL_INSTRUCTIONS} Determine the title in this order: 1. Read the USER messages first. Identify the latest explicit durable goal. The original subject remains the subject until the user clearly changes what the thread is about. @@ -312,6 +321,9 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { } const outputSchema = Schema.Struct({ title: Schema.String, + // Optional so older providers or cached structured-output responses remain + // decodable while the updated prompt teaches them to return a label. + label: Schema.optional(Schema.String), }); return { prompt, outputSchema }; diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index ad2911c20f7..eb8db86c60d 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -1,4 +1,4 @@ -import { TextGenerationError } from "@t3tools/contracts"; +import { TextGenerationError, type ThreadLabel } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; const isTextGenerationError = Schema.is(TextGenerationError); @@ -63,6 +63,31 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, 47).trimEnd()}...`; } +/** Normalize the model's thread label to one of the built-in label values. */ +export function sanitizeThreadLabel(raw: unknown): ThreadLabel | undefined { + if (typeof raw !== "string") { + return undefined; + } + + const normalized = raw + .trim() + .toLowerCase() + .replace(/[_\s]+/g, "-"); + switch (normalized) { + case "bug": + return "bug"; + case "feature": + return "feature"; + case "review": + return "review"; + case "new-build": + case "newbuild": + return "new-build"; + default: + return undefined; + } +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd577..7b2beb6d3c2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { LoaderIcon, SearchIcon, SquarePenIcon, + TagIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; @@ -47,8 +48,10 @@ import { type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, + type ThreadLabel, ThreadId, } from "@t3tools/contracts"; +import { THREAD_LABEL_OPTIONS, threadLabelDisplayName } from "@t3tools/shared/threadLabels"; import { parseScopedThreadKey, scopedProjectKey, @@ -78,6 +81,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { readThreadShell, + readEnvironmentSupportsLabels, useProject, useProjects, useThreadShells, @@ -710,21 +714,31 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr onDoubleClick={handleRenameInputClick} /> ) : ( - - - {thread.title} - - } - /> - - {thread.title} - - + <> + + + {thread.title} + + } + /> + + {thread.title} + + + {thread.label ? ( + + {threadLabelDisplayName(thread.label)} + + ) : null} + )}
@@ -1054,6 +1068,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( interface SidebarProjectItemProps { project: SidebarProjectSnapshot; + threadLabelFilter: ThreadLabel | null; + setThreadLabel: ReturnType["setThreadLabel"]; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; newThreadShortcutLabel: string | null; @@ -1074,6 +1090,8 @@ interface SidebarProjectItemProps { const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { const { project, + threadLabelFilter, + setThreadLabel, isThreadListExpanded, activeRouteThreadKey, newThreadShortcutLabel, @@ -1254,7 +1272,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + projectThreads.filter( + (thread) => + thread.archivedAt === null && + (threadLabelFilter === null || thread.label === threadLabelFilter), + ), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -1267,7 +1289,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [projectThreads, threadLabelFilter, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -2111,11 +2133,29 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; + const supportsLabels = readEnvironmentSupportsLabels(thread.environmentId); const clicked = await api.contextMenu.show( [ ...(thread.branch ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), + ...(supportsLabels + ? [ + { + id: "label", + label: thread.label + ? `Label: ${threadLabelDisplayName(thread.label)}` + : "Add label", + children: [ + ...THREAD_LABEL_OPTIONS.map((option) => ({ + id: `label:${option.value}`, + label: option.label, + })), + ...(thread.label ? [{ id: "label:clear", label: "Clear label" }] : []), + ], + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -2176,6 +2216,25 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; } + if (clicked?.startsWith("label:")) { + const selectedLabel = clicked.slice("label:".length); + const labelOption = THREAD_LABEL_OPTIONS.find((option) => option.value === selectedLabel); + const result = await setThreadLabel( + threadRef, + selectedLabel === "clear" ? null : (labelOption?.value ?? null), + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update thread label", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { const confirmed = await api.dialogs.confirm( @@ -2209,6 +2268,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec markThreadUnread, memberProjectByScopedKey, project.workspaceRoot, + setThreadLabel, startThreadRename, ], ); @@ -2744,6 +2804,9 @@ interface SidebarProjectsContentProps { handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; + setThreadLabel: ReturnType["setThreadLabel"]; + threadLabelFilter: ThreadLabel | null; + setThreadLabelFilter: (label: ThreadLabel | null) => void; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; @@ -2784,6 +2847,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread, archiveThread, deleteThread, + setThreadLabel, + threadLabelFilter, + setThreadLabelFilter, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, @@ -2884,6 +2950,51 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( onThreadSortOrderChange={handleThreadSortOrderChange} onThreadPreviewCountChange={handleThreadPreviewCountChange} /> + + + + } + > + + + + {threadLabelFilter + ? `Label: ${threadLabelDisplayName(threadLabelFilter)}` + : "Filter by label"} + + + + + setThreadLabelFilter(value === "all" ? null : (value as ThreadLabel)) + } + > + + All labels + + {THREAD_LABEL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + ( s.sidebarThreadPreviewCount); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { archiveThread, deleteThread, setThreadLabel } = useThreadActions(); + const [threadLabelFilter, setThreadLabelFilter] = useState(null); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3260,8 +3376,13 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], + () => + sidebarThreads.filter( + (thread) => + thread.archivedAt === null && + (threadLabelFilter === null || thread.label === threadLabelFilter), + ), + [sidebarThreads, threadLabelFilter], ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ @@ -3300,7 +3421,9 @@ export default function Sidebar() { sortedProjects.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, + (thread) => + thread.archivedAt === null && + (threadLabelFilter === null || thread.label === threadLabelFilter), ), sidebarThreadSortOrder, ); @@ -3339,6 +3462,7 @@ export default function Sidebar() { projectExpandedById, routeThreadKey, sortedProjects, + threadLabelFilter, threadsByProjectKey, ], ); @@ -3614,6 +3738,9 @@ export default function Sidebar() { handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} + setThreadLabel={setThreadLabel} + threadLabelFilter={threadLabelFilter} + setThreadLabelFilter={setThreadLabelFilter} sortedProjects={sortedProjects} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 6e4bdc0fc2f..615d448aa2d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -12,7 +12,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ScopedThreadRef, SidebarProjectGroupingMode, ThreadLabel } from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -33,6 +33,7 @@ import { SearchIcon, ServerIcon, SquarePenIcon, + TagIcon, TerminalIcon, Trash2Icon, Undo2Icon, @@ -83,6 +84,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { THREAD_LABEL_OPTIONS, threadLabelDisplayName } from "@t3tools/shared/threadLabels"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -230,6 +232,26 @@ function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; } +const THREAD_LABEL_CLASS_NAMES: Record = { + bug: "bg-red-500/10 text-red-700 dark:text-red-300", + feature: "bg-violet-500/10 text-violet-700 dark:text-violet-300", + review: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + "new-build": "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", +}; + +function ThreadLabelBadge({ label }: { readonly label: ThreadLabel }) { + return ( + + {threadLabelDisplayName(label)} + + ); +} + function SidebarV2ThreadTooltip({ thread, projectTitle, @@ -451,6 +473,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const threadLabel = thread.label ?? null; const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); @@ -836,6 +859,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} + {threadLabel ? : null} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1037,8 +1061,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
+
{title} + {threadLabel ? : null} {isRegeneratingTitle ? ( Regenerating title @@ -1141,6 +1166,7 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { threadId: thread.id, }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const threadLabel = thread.label ?? null; return (
  • @@ -1176,6 +1202,7 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { fallbackIcon={MessageSquareIcon} /> {thread.title} + {threadLabel ? : null} {threadTimeLabel(thread)} @@ -1216,6 +1243,7 @@ export default function SidebarV2() { pinThread, unpinThread, deleteThread, + setThreadLabel, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -1407,6 +1435,7 @@ export default function SidebarV2() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + const [labelFilter, setLabelFilter] = useState(null); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -1606,7 +1635,8 @@ export default function SidebarV2() { (thread) => thread.archivedAt === null && (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + (labelFilter === null || thread.label === labelFilter), ); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -1664,6 +1694,7 @@ export default function SidebarV2() { }, [ autoSettleAfterDays, changeRequestStateByKey, + labelFilter, nowMinute, scopedProjectKeys, serverConfigs, @@ -1721,7 +1752,7 @@ export default function SidebarV2() { // filter context changes so a scope/search flip never inherits a deep // page state. const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); - const settledResetKey = projectScopeKey ?? "all"; + const settledResetKey = `${projectScopeKey ?? "all"}:${labelFilter ?? "all"}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; @@ -2368,6 +2399,7 @@ export default function SidebarV2() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; + const threadLabel = thread.label ?? null; const threadWorkspacePath = thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? @@ -2386,6 +2418,8 @@ export default function SidebarV2() { const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; + const supportsLabels = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadLabels === true; const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); @@ -2435,6 +2469,23 @@ export default function SidebarV2() { }, ] : []), + ...(supportsLabels + ? [ + { + id: "label", + label: threadLabel + ? `Label: ${threadLabelDisplayName(threadLabel)}` + : "Add label", + children: [ + ...THREAD_LABEL_OPTIONS.map((option) => ({ + id: `label:${option.value}`, + label: option.label, + })), + ...(threadLabel ? [{ id: "label:clear", label: "Clear label" }] : []), + ], + }, + ] + : []), { id: "rename", label: "Rename thread" }, ...(supportsTitleRegeneration ? [ @@ -2461,6 +2512,25 @@ export default function SidebarV2() { if (preset) attemptSnooze(threadRef, preset); return; } + if (clicked.value?.startsWith("label:")) { + const selectedLabel = clicked.value.slice("label:".length); + const labelOption = THREAD_LABEL_OPTIONS.find((option) => option.value === selectedLabel); + const result = await setThreadLabel( + threadRef, + selectedLabel === "clear" ? null : (labelOption?.value ?? null), + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update thread label", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } switch (clicked.value) { case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it @@ -2588,6 +2658,7 @@ export default function SidebarV2() { markThreadUnread, projectCwdByKey, serverConfigs, + setThreadLabel, startThreadRename, updateThreadMetadata, ], @@ -2844,6 +2915,54 @@ export default function SidebarV2() { + + + + } + /> + } + > + + + + {labelFilter + ? `Label: ${threadLabelDisplayName(labelFilter)}` + : "Filter by label"} + + + + + setLabelFilter(value === "all" ? null : (value as ThreadLabel)) + } + > + + All labels + + {THREAD_LABEL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + ()( + "ThreadLabelsUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support thread labels yet. Update the server to use labels."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -130,6 +148,9 @@ export function useThreadActions() { const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false, }); + const updateThreadMetadataMutation = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -532,6 +553,26 @@ export function useThreadActions() { [unpinThreadMutation], ); + const setThreadLabel = useCallback( + async (target: ScopedThreadRef, label: ThreadLabel | null) => { + if (!readEnvironmentSupportsLabels(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadLabelsUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return updateThreadMetadataMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, label }, + }); + }, + [updateThreadMetadataMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -627,6 +668,7 @@ export function useThreadActions() { unsnoozeThread, pinThread, unpinThread, + setThreadLabel, }), [ archiveThread, @@ -639,6 +681,8 @@ export function useThreadActions() { unpinThread, unsettleThread, unsnoozeThread, + setThreadLabel, + updateThreadMetadataMutation, ], ); } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3f82973045c..2f3dd8363bd 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -250,6 +250,15 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo ); } +/** Whether the environment's server understands the label field on + * thread.meta.update. */ +export function readEnvironmentSupportsLabels(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadLabels === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/docs/README.md b/docs/README.md index bc359826a04..0d65efae59b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) +- [Thread labels](./user/thread-labels.md) - [Keyboard shortcuts](./user/keybindings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/user/thread-labels.md b/docs/user/thread-labels.md new file mode 100644 index 00000000000..86f777ce193 --- /dev/null +++ b/docs/user/thread-labels.md @@ -0,0 +1,9 @@ +# Thread Labels + +Use a lightweight label to keep related threads together. T3 Code supports **Bug**, **Feature**, **Review**, and **New Build**. + +To label a thread, open its context menu and choose **Add label**. Choose a different label to replace the current one, or choose **Clear label** to remove it. Labels sync with the environment, so the same thread keeps its label when you open it from the web app, desktop app, or mobile app. + +When AI generates a new thread title, it also chooses a label when the thread has not been labeled manually. Manual labels are always preserved. + +Use the tag filter in the thread list to show only threads with a selected label. Choosing **All labels** returns the complete list. diff --git a/output/playwright/thread-labels-after.png b/output/playwright/thread-labels-after.png new file mode 100644 index 00000000000..05139ab131f Binary files /dev/null and b/output/playwright/thread-labels-after.png differ diff --git a/output/playwright/thread-labels-before.png b/output/playwright/thread-labels-before.png new file mode 100644 index 00000000000..15fd7ca2448 Binary files /dev/null and b/output/playwright/thread-labels-before.png differ diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 30e8ef58248..a332a4f5baf 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -62,6 +62,7 @@ export function mergeEnvironmentThread( snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, + label: shell.label, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a34..8453df27739 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -305,6 +305,45 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } }); + + it("sets and clears a thread label", () => { + const labeled = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + label: "bug", + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + + expect(labeled.kind).toBe("updated"); + if (labeled.kind !== "updated") return; + expect(labeled.thread.label).toBe("bug"); + + const cleared = applyThreadDetailEvent(labeled.thread, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-04-01T07:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + label: null, + updatedAt: "2026-04-01T07:00:00.000Z", + }, + }); + + expect(cleared.kind).toBe("updated"); + if (cleared.kind === "updated") { + expect(cleared.thread.label).toBeNull(); + } + }); }); describe("thread.message-sent", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 0c6649f3868..21aebf44b18 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -94,6 +94,7 @@ export function applyThreadDetailEvent( settledAt: null, snoozedUntil: null, snoozedAt: null, + label: null, deletedAt: null, messages: [], proposedPlans: [], @@ -204,6 +205,7 @@ export function applyThreadDetailEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.label !== undefined ? { label: event.payload.label } : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4c44a959655..204865a9f12 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), + /** Server understands the label field on thread.meta.update. */ + threadLabels: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..800dfffc93e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,6 +19,7 @@ export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./orchestration.ts"; +export * from "./thread.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c9baa6ac670..23bb3310f6c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -21,6 +21,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { ThreadLabel } from "./thread.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -378,6 +379,8 @@ export const OrchestrationThread = Schema.Struct({ // thread renders in the pinned block and never classifies into a shelf. // Optional so payloads from pre-pinning servers still decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Optional so snapshots from pre-label servers remain decodable. + label: Schema.optional(Schema.NullOr(ThreadLabel)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -433,6 +436,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + label: Schema.optional(Schema.NullOr(ThreadLabel)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -655,6 +659,8 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + label: Schema.optional(Schema.NullOr(ThreadLabel)), + expectedLabel: Schema.optional(Schema.NullOr(ThreadLabel)), }).check( Schema.makeFilter( (input) => @@ -910,6 +916,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + label: Schema.optional(ThreadLabel), }); const InternalOrchestrationCommand = Schema.Union([ @@ -1076,6 +1083,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + label: Schema.optional(Schema.NullOr(ThreadLabel)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/thread.ts b/packages/contracts/src/thread.ts new file mode 100644 index 00000000000..f501462e7e7 --- /dev/null +++ b/packages/contracts/src/thread.ts @@ -0,0 +1,5 @@ +import * as Schema from "effect/Schema"; + +/** Built-in labels available for lightweight thread organization. */ +export const ThreadLabel = Schema.Literals(["bug", "feature", "review", "new-build"]); +export type ThreadLabel = typeof ThreadLabel.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8cdae3e5160..5b515f0be51 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -167,6 +167,10 @@ "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" }, + "./threadLabels": { + "types": "./src/threadLabels.ts", + "import": "./src/threadLabels.ts" + }, "./relayClient": { "types": "./src/relayClient.ts", "import": "./src/relayClient.ts" diff --git a/packages/shared/src/threadLabels.test.ts b/packages/shared/src/threadLabels.test.ts new file mode 100644 index 00000000000..3c250601036 --- /dev/null +++ b/packages/shared/src/threadLabels.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { THREAD_LABEL_OPTIONS, threadLabelDisplayName } from "./threadLabels.ts"; + +describe("thread labels", () => { + it("includes New Build in the shared label options", () => { + expect(THREAD_LABEL_OPTIONS).toContainEqual({ value: "new-build", label: "New Build" }); + expect(threadLabelDisplayName("new-build")).toBe("New Build"); + }); +}); diff --git a/packages/shared/src/threadLabels.ts b/packages/shared/src/threadLabels.ts new file mode 100644 index 00000000000..e6650110541 --- /dev/null +++ b/packages/shared/src/threadLabels.ts @@ -0,0 +1,15 @@ +import type { ThreadLabel } from "@t3tools/contracts"; + +export const THREAD_LABEL_OPTIONS: ReadonlyArray<{ + readonly value: ThreadLabel; + readonly label: string; +}> = [ + { value: "bug", label: "Bug" }, + { value: "feature", label: "Feature" }, + { value: "review", label: "Review" }, + { value: "new-build", label: "New Build" }, +]; + +export function threadLabelDisplayName(label: ThreadLabel): string { + return THREAD_LABEL_OPTIONS.find((option) => option.value === label)?.label ?? label; +}