diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07e6daff3abac..b3e97d4d2ee01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: pull_request: push: branches: @@ -469,7 +470,8 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} - runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-6vcpu-macos-26' || 'macos-26' }} + # Static analysis supports both architectures; Intel avoids the constrained hosted ARM pool. + runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-6vcpu-macos-26' || 'macos-26-intel' }} timeout-minutes: 10 steps: - name: Checkout diff --git a/apps/web/src/components/home/HomeActivity.logic.test.ts b/apps/web/src/components/home/HomeActivity.logic.test.ts index 90fda149bb14d..05f7082a77732 100644 --- a/apps/web/src/components/home/HomeActivity.logic.test.ts +++ b/apps/web/src/components/home/HomeActivity.logic.test.ts @@ -36,7 +36,163 @@ const completed = () => { latestRun: { ...thread.latestRun, status: "completed" as const, completedAt: now }, }; }; +const limited = () => { + const thread = completed(); + return { + ...thread, + runtime: { + ...running().runtime, + status: "failed" as const, + activeRunId: null, + lastErrorClass: "usage_limit" as const, + }, + latestRun: { ...thread.latestRun, status: "failed" as const }, + }; +}; describe("Home activity", () => { + it("keeps an old child failure from masking a parent waiting for new agents", () => { + const parent = { + ...completed(), + lastVisitedAt: now, + latestRun: { + ...completed().latestRun, + requestedAt: "2026-10-23T11:55:00.000Z", + startedAt: "2026-10-23T11:55:00.000Z", + }, + runtime: { ...running().runtime, status: "idle" as const, activeRunId: null }, + pendingBackgroundTasks: [ + { kind: "subagent" as const, taskId: "new-review", childThreadId: ThreadId.make("review") }, + ], + }; + const oldFailure = { + ...limited(), + id: ThreadId.make("old-failure"), + latestRun: { ...limited().latestRun, completedAt: start }, + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent" as const, + }, + }; + const reviewer = { ...running(), id: ThreadId.make("review"), lineage: oldFailure.lineage }; + expect(buildHomeActivity([parent, oldFailure, reviewer], now)[0]).toMatchObject({ + kind: "working", + label: "Waiting for agents", + }); + expect( + buildHomeActivity([{ ...parent, lastVisitedAt: null }, oldFailure, reviewer], now)[0]?.kind, + ).toBe("working"); + }); + it("acknowledges child failures when either the child or its parent has been read", () => { + const parent = completed(); + const child = { + ...limited(), + id: ThreadId.make("failed-child"), + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent" as const, + }, + }; + expect(buildHomeActivity([parent, { ...child, lastVisitedAt: now }], now)[0]?.kind).toBe( + "unread", + ); + expect(buildHomeActivity([{ ...parent, lastVisitedAt: now }, child], now)[0]?.kind).toBe( + "done", + ); + expect( + buildHomeActivity( + [ + { ...parent, lastVisitedAt: now }, + { ...child, hasPendingApprovals: true }, + ], + now, + )[0]?.label, + ).toBe("Approval needed"); + }); + it("keeps new failures visible after an earlier parent visit", () => { + const parent = { ...running(), lastVisitedAt: start }; + const child = { + ...limited(), + id: ThreadId.make("failed-child"), + lastVisitedAt: start, + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent" as const, + }, + }; + expect(buildHomeActivity([parent, child], now)[0]).toMatchObject({ + kind: "attention", + label: "Usage limit", + }); + }); + it("hides an acknowledged usage limit while the thread is snoozed", () => { + const thread = { + ...limited(), + lastVisitedAt: now, + snoozedAt: now, + snoozedUntil: "2026-10-24T12:00:00.000Z", + }; + expect(buildHomeActivity([thread], now)).toEqual([]); + }); + it("keeps a snoozed child usage limit out of its parent's attention status", () => { + const parent = { ...completed(), lastVisitedAt: now }; + const child = { + ...limited(), + id: ThreadId.make("limited-child"), + lastVisitedAt: now, + snoozedAt: now, + snoozedUntil: "2026-10-24T12:00:00.000Z", + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent" as const, + }, + }; + expect(buildHomeActivity([parent, child], now)[0]).toMatchObject({ + kind: "done", + label: "Done", + }); + expect(buildHomeActivity([running(), child], now)[0]).toMatchObject({ + kind: "working", + label: "Working", + }); + const freshFailure = { ...child, snoozedAt: start, lastVisitedAt: start }; + expect( + buildHomeActivity([{ ...parent, lastVisitedAt: start }, freshFailure], now)[0], + ).toMatchObject({ + kind: "attention", + label: "Usage limit", + }); + const expired = { ...child, snoozedUntil: now }; + expect(buildHomeActivity([parent, expired], now)[0]?.kind).toBe("done"); + }); + it("honors a parent snooze for old child failures and surfaces new requests", () => { + const parent = { + ...completed(), + lastVisitedAt: start, + snoozedAt: now, + snoozedUntil: "2026-10-24T12:00:00.000Z", + }; + const child = { + ...limited(), + id: ThreadId.make("limited-child"), + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent" as const, + }, + }; + expect(buildHomeActivity([parent, child], now)).toEqual([]); + expect(buildHomeActivity([{ ...parent, snoozedAt: start }, child], now)[0]).toMatchObject({ + kind: "attention", + label: "Usage limit", + }); + expect( + buildHomeActivity([parent, { ...child, hasPendingApprovals: true }], now)[0], + ).toMatchObject({ kind: "attention", label: "Approval needed" }); + }); it("lets completed threads leave Working while persistent commands keep running", () => { const thread = { ...completed(), diff --git a/apps/web/src/components/home/HomeActivity.logic.ts b/apps/web/src/components/home/HomeActivity.logic.ts index 8d2c3dd8865e9..633982f450581 100644 --- a/apps/web/src/components/home/HomeActivity.logic.ts +++ b/apps/web/src/components/home/HomeActivity.logic.ts @@ -27,8 +27,14 @@ export interface HomeActivity { readonly nativeAgentCount: number; } const keyOf = (thread: Thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); -const needsAttention = (thread: Thread) => - ["approval", "input", "failed", "limited"].includes(resolveSidebarThreadStatus(thread)); +const needsAttention = (thread: Thread, acknowledgedAt: readonly (string | null | undefined)[]) => { + const status = resolveSidebarThreadStatus(thread); + if (status === "approval" || status === "input") return true; + if (status !== "failed" && status !== "limited") return false; + const failedAt = Date.parse(thread.latestRun?.completedAt ?? thread.runtime?.updatedAt ?? ""); + if (!Number.isFinite(failedAt)) return true; + return !acknowledgedAt.some((at) => at && Date.parse(at) >= failedAt); +}; const doingWork = (thread: Thread) => threadRuntimeIsActive(thread.runtime) || backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks); @@ -70,14 +76,24 @@ export function buildHomeActivity( const rows: HomeActivity[] = []; for (const [key, { thread, members }] of groups) { if (thread.settledOverride === "settled") continue; - const active = members.filter( + const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localVisits[key]); + const attention = members.find( (member) => - doingWork(member) || (member.settledOverride !== "settled" && needsAttention(member)), + member.settledOverride !== "settled" && + needsAttention(member, [ + resolveThreadLastVisitedAt(member.lastVisitedAt, localVisits[keyOf(member)]), + lastVisitedAt, + // Continuing the parent handles results from older child tasks. + member !== thread ? (thread.latestRun?.startedAt ?? thread.latestRun?.requestedAt) : null, + ]) && + !effectiveSnoozed(member, { now }) && + !effectiveSnoozed( + { ...member, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, + { now }, + ), ); - if (effectiveSnoozed(thread, { now }) && active.length === 0) continue; - const attention = active.find(needsAttention); - const working = active.filter(doingWork); - const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localVisits[key]); + const working = members.filter(doingWork); + if (effectiveSnoozed(thread, { now }) && !attention && working.length === 0) continue; const unread = Number.isFinite(Date.parse(thread.latestRun?.completedAt ?? "")) && (!lastVisitedAt || hasUnseenCompletion({ ...thread, lastVisitedAt })); @@ -99,7 +115,10 @@ export function buildHomeActivity( : attentionStatus === "limited" ? "Usage limit" : kind === "working" - ? thread.blockedOnThreadWait + ? thread.blockedOnThreadWait || + (!threadRuntimeIsActive(thread.runtime) && + (working.some((member) => member !== thread) || + thread.pendingBackgroundTasks.some((task) => task.kind === "subagent"))) ? "Waiting for agents" : "Working" : kind === "unread" diff --git a/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx b/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx index f93e19f44adbb..25cdef6e0d2fd 100644 --- a/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx +++ b/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx @@ -1,9 +1,19 @@ import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + type ServerProvider, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { act, render, fakeTimers } from "../../../test/render"; import { makeThreadFixture } from "../../test-fixtures"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useComposerDraftStore } from "../../composerDraftStore"; const state = vi.hoisted(() => ({ threads: [] as EnvironmentThreadShell[], @@ -11,7 +21,8 @@ const state = vi.hoisted(() => ({ projects: [] as never[], visits: {}, environments: [{ environmentId: "local", label: "This Mac", connection: { phase: "connected" } }], - providers: new Map(), + providers: new Map(), + settings: null as UnifiedSettings | null, show: vi.fn(), })); vi.mock("../../state/entities", async () => { @@ -28,6 +39,7 @@ vi.mock("../../state/entities", async () => { () => state.threads, ), useProjects: () => state.projects, + useProject: () => null, useAllEnvironmentShellsBootstrapped: () => true, }; }); @@ -38,15 +50,22 @@ vi.mock("../../state/environments", () => ({ vi.mock("../../uiStateStore", () => ({ useUiStateStore: () => state.visits, })); +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: () => state.settings ?? DEFAULT_UNIFIED_SETTINGS, +})); vi.mock("../../hooks/useThreadActionMenu", () => ({ useThreadActionMenu: () => ({ openMenu: state.show, closeMenu: () => {} }), })); vi.mock("./HomeThreadRenameDialog", () => ({ HomeThreadRenameDialog: () => null })); +vi.mock("./HomeCreateActions", () => ({ HomeCreateActions: () => null })); import { HomeActivity } from "./HomeActivityOverview"; function setup() { state.listeners.clear(); state.show.mockReset(); + state.providers.clear(); + state.settings = null; + useComposerDraftStore.setState({ draftsByThreadKey: {} }); const runId = RunId.make("run"); const working = makeThreadFixture({ id: ThreadId.make("mcp:working"), @@ -155,3 +174,151 @@ it("moves completed work to Unread and Done while its preview server keeps runni .not.toBeInTheDocument(); await expect.element(screen.getByRole("region", { name: "Done", exact: true })).toBeVisible(); }); + +it("clears an old child usage limit when the parent continues and waits for new reviewers", async () => { + const { working } = setup(); + const child = { + ...working, + id: ThreadId.make("old-child"), + title: "Old validation task", + runtime: { + ...working.runtime!, + status: "failed" as const, + activeRunId: null, + lastErrorClass: "usage_limit" as const, + }, + latestRun: { + ...working.latestRun!, + status: "failed" as const, + completedAt: "2026-10-23T12:01:00Z", + }, + lineage: { + rootThreadId: working.id, + parentThreadId: working.id, + relationshipToParent: "subagent" as const, + }, + }; + state.threads = [working, child]; + await using screen = await render(, { router: "/home" }); + await expect.element(screen.getByRole("region", { name: "Needs attention" })).toBeVisible(); + await expect.element(screen.getByText("Usage limit", { exact: true })).toBeVisible(); + await act(() => { + state.threads = [ + { + ...working, + runtime: { ...working.runtime!, status: "idle", activeRunId: null }, + latestRun: { + ...working.latestRun!, + runId: RunId.make("continued-run"), + status: "completed", + requestedAt: "2026-10-23T12:02:00Z", + startedAt: "2026-10-23T12:02:00Z", + completedAt: "2026-10-23T12:03:00Z", + }, + pendingBackgroundTasks: [{ kind: "subagent", taskId: "new-review" }], + }, + child, + ]; + for (const listener of state.listeners) listener(); + }); + await expect + .element(screen.getByRole("region", { name: "Needs attention" })) + .not.toBeInTheDocument(); + await expect.element(screen.getByRole("region", { name: "Working", exact: true })).toBeVisible(); + await expect.element(screen.getByText("Waiting for agents", { exact: true })).toBeVisible(); +}); + +it("follows the main thread's model picker through local model and provider changes", async () => { + const { working } = setup(); + const primary = ProviderInstanceId.make("opencode-home-primary"); + const secondary = ProviderInstanceId.make("opencode-home-secondary"); + const driver = ProviderDriverKind.make("opencode"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [primary]: { driver, enabled: true, config: {} }, + [secondary]: { driver, enabled: true, config: {} }, + }, + }; + const models = [ + { slug: "invented/first", name: "First model", isCustom: false, capabilities: {} }, + { slug: "invented/second", name: "Second model", isCustom: false, capabilities: {} }, + ]; + state.providers.set( + working.environmentId, + [ + { instanceId: primary, displayName: "Unlock Digital" }, + { instanceId: secondary, displayName: "Unlock Digital Review" }, + ].map((identity): ServerProvider => ({ + ...identity, + driver, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-23T12:00:00Z", + models, + slashCommands: [], + skills: [], + })), + ); + const parent = { + ...working, + modelSelection: { instanceId: primary, model: "invented/first" }, + runtime: { ...working.runtime!, providerInstanceId: primary, providerName: "opencode" }, + }; + state.threads = [ + parent, + { + ...working, + id: ThreadId.make("review-child"), + title: "Review task", + modelSelection: { instanceId: secondary, model: "invented/second" }, + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent", + }, + }, + ]; + const threadRef = scopeThreadRef(parent.environmentId, parent.id); + // Another environment can have a thread and provider with the same IDs. + useComposerDraftStore + .getState() + .setModelSelection( + scopeThreadRef(EnvironmentId.make("other-environment"), parent.id), + { instanceId: primary, model: "invented/second" }, + { explicit: true }, + ); + await using screen = await render(, { router: "/home" }); + await expect.element(screen.getByLabelText("Unlock Digital · First model")).toBeVisible(); + await act(() => { + useComposerDraftStore + .getState() + .setModelSelection( + threadRef, + { instanceId: primary, model: "invented/second" }, + { explicit: true }, + ); + }); + await expect.element(screen.getByLabelText("Unlock Digital · Second model")).toBeVisible(); + await act(() => { + useComposerDraftStore + .getState() + .setModelSelection( + threadRef, + { instanceId: secondary, model: "invented/first" }, + { explicit: true }, + ); + }); + await expect.element(screen.getByLabelText("Unlock Digital Review · First model")).toBeVisible(); + await act(() => { + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + state.threads = [ + { ...parent, modelSelection: { instanceId: primary, model: "invented/second" } }, + ]; + for (const listener of state.listeners) listener(); + }); + await expect.element(screen.getByLabelText("Unlock Digital · Second model")).toBeVisible(); +}); diff --git a/apps/web/src/components/home/HomeActivityOverview.tsx b/apps/web/src/components/home/HomeActivityOverview.tsx index 390adc15098a4..17d8b2bb8defc 100644 --- a/apps/web/src/components/home/HomeActivityOverview.tsx +++ b/apps/web/src/components/home/HomeActivityOverview.tsx @@ -17,6 +17,7 @@ import { type MouseEvent, } from "react"; import type { ServerProvider } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { scopedProjectKey, scopeProjectRef, @@ -25,10 +26,24 @@ import { import { useThreadShells, useProjects, + useProject, useAllEnvironmentShellsBootstrapped, } from "../../state/entities"; import { useEnvironments, useEnvironmentProviders } from "../../state/environments"; import { useNowMinute } from "../../hooks/useNowMinute"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { + deriveEffectiveComposerModelState, + useComposerDraftModelState, +} from "../../composerDraftStore"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { getAppModelOptionsForInstance } from "../../modelSelection"; +import { deriveLockedProvider, resolveComposerProviderSelection } from "../ChatView.logic"; +import { getTriggerDisplayModelName } from "../chat/providerIconUtils"; import { useUiStateStore } from "../../uiStateStore"; import { buildThreadRouteParams } from "../../threadRoutes"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -42,6 +57,7 @@ import { } from "./HomeActivity.logic"; import { useThreadActionMenu } from "../../hooks/useThreadActionMenu"; import { HomeThreadRenameDialog } from "./HomeThreadRenameDialog"; +import { HomeCreateActions } from "./HomeCreateActions"; import { toastManager } from "../ui/toast"; import styles from "./HomeActivity.module.css"; import { cn } from "../../lib/utils"; @@ -145,12 +161,9 @@ export function HomeActivity() { )} >
-
-

Workspace activity

-

Your agents at a glance

-

- Your agents work here. Open a thread when it needs your attention. -

+
+

Workspace activity

+

{connected} {connected === 1 ? "environment" : "environments"} connected @@ -181,7 +194,7 @@ export function HomeActivity() { A quiet workspace - Your threads appear here as you work. Start a thread from the sidebar to get going. + Your threads appear here as you work. Start a new thread to get going. @@ -265,14 +278,67 @@ function ActivityRow({ readonly onOpen: (event: MouseEvent) => void; readonly onMenu: (position: { x: number; y: number }, at: string) => void; }) { - const current = providers.find( - (provider) => provider.instanceId === row.thread.modelSelection.instanceId, - ); + const thread = row.thread; + const draft = useComposerDraftModelState(scopeThreadRef(thread.environmentId, thread.id)); + const settings = useEnvironmentSettings(thread.environmentId); + const threadProject = useProject(scopeProjectRef(thread.environmentId, thread.projectId)); + const projectModelSelection = resolveProjectSettings(settings, thread.projectId, threadProject) + .settings.defaultModelSelection; + const { selectedProviderEntry } = resolveComposerProviderSelection({ + entries: sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(providers), + settings, + thread.projectId, + ), + ), + candidateInstanceIds: [ + draft.activeProvider, + thread.runtime?.providerInstanceId, + thread.modelSelection.instanceId, + projectModelSelection?.instanceId, + ], + lockedProvider: deriveLockedProvider({ + thread, + selectedProvider: draft.activeProvider, + threadProvider: thread.modelSelection.instanceId, + providers, + }), + lockedInstanceId: thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId, + }); + const instanceId = selectedProviderEntry?.instanceId ?? thread.modelSelection.instanceId; + const model = selectedProviderEntry + ? deriveEffectiveComposerModelState({ + draft, + providers, + settings, + selectedProvider: selectedProviderEntry.driverKind, + selectedInstanceId: instanceId, + threadModelSelection: thread.modelSelection, + projectModelSelection, + }).selectedModel + : thread.modelSelection.model; + const modelOption = selectedProviderEntry + ? getAppModelOptionsForInstance(settings, selectedProviderEntry, model).find( + (option) => option.slug === model, + ) + : undefined; + const modelName = modelOption ? getTriggerDisplayModelName(modelOption) : model; + const current = + selectedProviderEntry?.snapshot ?? + providers.find((provider) => provider.instanceId === instanceId); const providerName = + selectedProviderEntry?.displayName ?? current?.displayName ?? - row.thread.runtime?.providerName ?? - row.thread.modelSelection.instanceId; - const uniqueIds = [...new Set(row.members.map((member) => member.modelSelection.instanceId))]; + (thread.runtime?.providerInstanceId === instanceId ? thread.runtime.providerName : null) ?? + instanceId; + const uniqueIds = [ + ...new Set( + row.members.map((member) => + member === thread ? instanceId : member.modelSelection.instanceId, + ), + ), + ]; const roster = uniqueIds .map((id) => providers.find((provider) => provider.instanceId === id)) .filter((provider): provider is ServerProvider => provider !== undefined); @@ -340,11 +406,8 @@ function ActivityRow({

{preview &&

{preview}

}
- - {providerName} · {row.thread.modelSelection.model} + + {providerName} · {modelName} · {project} diff --git a/apps/web/src/components/home/HomeCreateActions.tsx b/apps/web/src/components/home/HomeCreateActions.tsx new file mode 100644 index 0000000000000..18d3beb08776a --- /dev/null +++ b/apps/web/src/components/home/HomeCreateActions.tsx @@ -0,0 +1,85 @@ +import { useAtomValue } from "@effect/atom-react"; +import { FolderPlusIcon, SquarePenIcon } from "lucide-react"; +import type { MouseEvent } from "react"; + +import { openCommandPalette } from "../../commandPaletteBus"; +import { useHandleNewThread } from "../../hooks/useHandleNewThread"; +import { useSidebarProjectGroups } from "../../hooks/useSidebarProjectGroups"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { startNewThreadFromContext } from "../../lib/chatThreadActions"; +import { primaryServerKeybindingsAtom } from "../../state/server"; +import { shouldCreateNewThreadInCurrentProject } from "../Sidebar.logic"; +import { Button } from "../ui/button"; +import { Kbd } from "../ui/kbd"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function HomeCreateActions() { + const context = useHandleNewThread(); + const projectGroups = useSidebarProjectGroups(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const shortcut = + shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : null); + const localShortcut = shortcutLabelForCommand(keybindings, "chat.newLocal"); + const createThread = (event: MouseEvent) => { + if (!shouldCreateNewThreadInCurrentProject(event.shiftKey, projectGroups.length)) { + openCommandPalette({ open: "new-thread-in" }); + return; + } + void startNewThreadFromContext({ + activeDraftThread: context.activeDraftThread, + activeThread: context.activeThread ?? undefined, + defaultProjectRef: context.defaultProjectRef, + handleNewThread: context.handleNewThread, + }).catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not start a thread", + description: error instanceof Error ? error.message : "Try creating the thread again.", + }); + }); + }; + + return ( +
+ + + } + > + + New thread + {shortcut && ( + + {shortcut} + + )} + + + {projectGroups.length > 1 + ? `Shift+click to use the current project${localShortcut ? ` (${localShortcut})` : ""}` + : projectGroups.length === 0 + ? "Add a project to start a thread" + : shortcut + ? `New thread (${shortcut})` + : "New thread"} + + + +
+ ); +}