From 19d88076babb350ab4283237189683ec0d803b6d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 16:42:25 -0700 Subject: [PATCH] Add iOS New Thread home screen widget - Register configurable NewThread widget for iOS - Sync recent project shortcuts and deep links into draft threads - Cover widget ordering, pinning, and layout behavior --- apps/mobile/app.config.ts | 26 ++ apps/mobile/src/Stack.tsx | 3 + .../threads/new-thread-widget.test.ts | 111 +++++++++ .../src/features/threads/new-thread-widget.ts | 63 +++++ .../threads/use-new-thread-widget-sync.ts | 43 ++++ apps/mobile/src/widgets/NewThread.test.ts | 227 ++++++++++++++++++ apps/mobile/src/widgets/NewThread.tsx | 203 ++++++++++++++++ 7 files changed, 676 insertions(+) create mode 100644 apps/mobile/src/features/threads/new-thread-widget.test.ts create mode 100644 apps/mobile/src/features/threads/new-thread-widget.ts create mode 100644 apps/mobile/src/features/threads/use-new-thread-widget-sync.ts create mode 100644 apps/mobile/src/widgets/NewThread.test.ts create mode 100644 apps/mobile/src/widgets/NewThread.tsx diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 098b830aca3..61bab64e38a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -181,6 +181,32 @@ const config: ExpoConfig = { description: "Shows the current state of active T3 Code agents.", supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], }, + { + name: "NewThread", + displayName: "New Thread", + description: "Start a new T3 Code thread right from your Home Screen.", + supportedFamilies: ["systemSmall", "systemMedium", "systemLarge"], + // Per-instance options in the long-press "Edit Widget" sheet. Enum + // parameters are compiled into Swift at prebuild, so the dynamic + // project list cannot be offered as a picker — pins are typed as + // text and matched against the synced projects inside the widget. + configuration: { + title: "New Thread", + description: "Pin specific projects; the rest fills with your latest activity.", + parameters: { + pinnedProjects: { + title: "Pinned projects (comma-separated)", + type: "string", + default: "", + }, + showRecent: { + title: "Fill with recent projects", + type: "boolean", + default: true, + }, + }, + }, + }, ], }, ], diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d68d6f2b97b..8a4ecf0a281 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -40,6 +40,7 @@ import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; +import { useNewThreadWidgetSync } from "./features/threads/use-new-thread-widget-sync"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; @@ -256,6 +257,8 @@ function RootStackLayout(props: { useThreadOutboxDrain(); // Presents the T3 Connect onboarding sheet after an in-session sign-in. useConnectOnboardingNavigation(); + // Keeps the NewThread home-screen widget's project shortcuts current. + useNewThreadWidgetSync(); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); diff --git a/apps/mobile/src/features/threads/new-thread-widget.test.ts b/apps/mobile/src/features/threads/new-thread-widget.test.ts new file mode 100644 index 00000000000..d19979b169b --- /dev/null +++ b/apps/mobile/src/features/threads/new-thread-widget.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; + +import { + makeNewThreadWidgetProps, + NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT, +} from "./new-thread-widget"; + +function makeProject(overrides: { + readonly id: string; + readonly environmentId?: string; + readonly title?: string; + readonly updatedAt?: string; +}): EnvironmentProject { + return { + id: overrides.id, + environmentId: overrides.environmentId ?? "env-1", + title: overrides.title ?? overrides.id, + updatedAt: overrides.updatedAt ?? "2026-07-01T00:00:00.000Z", + } as unknown as EnvironmentProject; +} + +function makeThread(overrides: { + readonly projectId: string; + readonly environmentId?: string; + readonly updatedAt: string; +}): EnvironmentThreadShell { + return { + id: `thread-${overrides.projectId}`, + projectId: overrides.projectId, + environmentId: overrides.environmentId ?? "env-1", + updatedAt: overrides.updatedAt, + } as unknown as EnvironmentThreadShell; +} + +describe("makeNewThreadWidgetProps", () => { + it("orders projects by their newest thread activity", () => { + const props = makeNewThreadWidgetProps( + [ + makeProject({ id: "stale", updatedAt: "2026-07-06T00:00:00.000Z" }), + makeProject({ id: "busy", updatedAt: "2026-07-01T00:00:00.000Z" }), + ], + [makeThread({ projectId: "busy", updatedAt: "2026-07-07T00:00:00.000Z" })], + ); + expect(props.projects?.map((project) => project.title)).toEqual(["busy", "stale"]); + }); + + it("falls back to the project's own updatedAt when it has no threads", () => { + const props = makeNewThreadWidgetProps( + [ + makeProject({ id: "old", updatedAt: "2026-07-01T00:00:00.000Z" }), + makeProject({ id: "fresh", updatedAt: "2026-07-07T00:00:00.000Z" }), + ], + [], + ); + expect(props.projects?.map((project) => project.title)).toEqual(["fresh", "old"]); + }); + + it("ignores thread activity from a same-named project in another environment", () => { + const props = makeNewThreadWidgetProps( + [ + makeProject({ id: "proj", environmentId: "env-1", title: "one" }), + makeProject({ + id: "proj", + environmentId: "env-2", + title: "two", + updatedAt: "2026-07-02T00:00:00.000Z", + }), + ], + [ + makeThread({ + projectId: "proj", + environmentId: "env-2", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ], + ); + expect(props.projects?.map((project) => project.title)).toEqual(["two", "one"]); + }); + + it("caps the payload at the widget's sync limit", () => { + const props = makeNewThreadWidgetProps( + Array.from({ length: NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT + 5 }, (_, n) => + makeProject({ + id: `p${n}`, + updatedAt: `2026-06-${String(n + 1).padStart(2, "0")}T00:00:00.000Z`, + }), + ), + [], + ); + expect(props.projects).toHaveLength(NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT); + expect(props.projects?.[0]?.title).toBe(`p${NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT + 4}`); + }); + + it("URL-encodes deep link parameters", () => { + const props = makeNewThreadWidgetProps( + [makeProject({ id: "p 1", environmentId: "env&1", title: "T3 & friends" })], + [], + ); + expect(props.projects?.[0]?.deepLink).toBe( + "/new/draft?environmentId=env%261&projectId=p%201&title=T3%20%26%20friends", + ); + }); + + it("returns an empty list when there are no projects", () => { + expect(makeNewThreadWidgetProps([], []).projects).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/threads/new-thread-widget.ts b/apps/mobile/src/features/threads/new-thread-widget.ts new file mode 100644 index 00000000000..07ea6a08a84 --- /dev/null +++ b/apps/mobile/src/features/threads/new-thread-widget.ts @@ -0,0 +1,63 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; + +import type { NewThreadWidgetProps } from "../../widgets/NewThread"; + +// Candidate pool synced to the widget. Deliberately larger than any family +// displays (medium shows 3 rows, large 7): the "pinned projects" widget +// configuration is per widget instance and only visible inside the widget +// process, so the layout matches pins against this pool — a pin should still +// resolve when its project is not among the most recent few. +export const NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT = 20; + +/** + * Builds the home-screen widget payload: the most-recently-active projects, + * each with a deep link into the new-task draft composer for that project. + * + * "Recently active" means the newest thread update in the project, falling + * back to the project's own updatedAt for projects with no threads — so the + * widget surfaces where the user actually works, not what was created last. + */ +export function makeNewThreadWidgetProps( + projects: ReadonlyArray, + threads: ReadonlyArray, +): NewThreadWidgetProps { + const latestThreadActivity = new Map(); + for (const thread of threads) { + const key = projectActivityKey(thread.environmentId, thread.projectId); + const previous = latestThreadActivity.get(key); + // ISO-8601 timestamps order lexicographically. + if (previous === undefined || thread.updatedAt > previous) { + latestThreadActivity.set(key, thread.updatedAt); + } + } + + const lastActivity = (project: EnvironmentProject): string => { + const threadActivity = latestThreadActivity.get( + projectActivityKey(project.environmentId, project.id), + ); + return threadActivity !== undefined && threadActivity > project.updatedAt + ? threadActivity + : project.updatedAt; + }; + + const ordered = [...projects].sort((a, b) => lastActivity(b).localeCompare(lastActivity(a))); + + return { + projects: ordered.slice(0, NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT).map((project) => ({ + title: project.title, + // Matches the NewTaskSheet > NewTaskDraft linking config in Stack.tsx; + // the widget layout prefixes the scheme after a safety check. + deepLink: + `/new/draft?environmentId=${encodeURIComponent(String(project.environmentId))}` + + `&projectId=${encodeURIComponent(String(project.id))}` + + `&title=${encodeURIComponent(project.title)}`, + })), + }; +} + +function projectActivityKey(environmentId: string, projectId: string): string { + return `${environmentId}\u0000${projectId}`; +} diff --git a/apps/mobile/src/features/threads/use-new-thread-widget-sync.ts b/apps/mobile/src/features/threads/use-new-thread-widget-sync.ts new file mode 100644 index 00000000000..9c3d1167a1f --- /dev/null +++ b/apps/mobile/src/features/threads/use-new-thread-widget-sync.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { Platform } from "react-native"; + +import { useProjects, useThreadShells } from "../../state/entities"; +import NewThreadWidget from "../../widgets/NewThread"; +import { makeNewThreadWidgetProps } from "./new-thread-widget"; + +// Last payload written to the widget timeline. Module-level (not React state) +// so navigator remounts don't rewrite an unchanged payload: every write ends +// in a WidgetCenter reload, and iOS throttles widgets that reload too often. +let lastSyncedSignature: string | null = null; + +/** + * Keeps the NewThread home-screen widget's project shortcuts in sync with the + * workspace. Writing the snapshot also persists the widget layout to the + * shared app group, which is what lets the widget render at all — so this + * must run on every launch, not only when projects change. + */ +export function useNewThreadWidgetSync(): void { + const projects = useProjects(); + const threads = useThreadShells(); + + useEffect(() => { + if (Platform.OS !== "ios") { + return; + } + const props = makeNewThreadWidgetProps(projects, threads); + const signature = JSON.stringify(props); + if (signature === lastSyncedSignature) { + return; + } + lastSyncedSignature = signature; + try { + NewThreadWidget.updateSnapshot(props); + } catch (error) { + // Clear the signature so the next data change retries the write. + lastSyncedSignature = null; + if (__DEV__) { + console.warn("[new-thread-widget] snapshot update failed", error); + } + } + }, [projects, threads]); +} diff --git a/apps/mobile/src/widgets/NewThread.test.ts b/apps/mobile/src/widgets/NewThread.test.ts new file mode 100644 index 00000000000..d02f615a6c1 --- /dev/null +++ b/apps/mobile/src/widgets/NewThread.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@expo/ui/swift-ui", () => ({ + HStack: "HStack", + Image: "Image", + Link: "Link", + Spacer: "Spacer", + Text: "Text", + VStack: "VStack", +})); + +vi.mock("@expo/ui/swift-ui/modifiers", () => ({ + containerBackground: (color: unknown) => ({ containerBackground: color }), + font: (value: unknown) => value, + foregroundStyle: (value: unknown) => value, + frame: (value: unknown) => value, + lineLimit: (value: unknown) => value, + padding: (value: unknown) => value, + resizable: (value: unknown) => value, + widgetURL: (value: unknown) => ({ widgetURL: value }), +})); + +vi.mock("expo-widgets", () => ({ + createWidget: vi.fn((name: string, layout: unknown) => ({ layout, name })), +})); + +import { + NewThread, + type NewThreadWidgetConfiguration, + type NewThreadWidgetProject, +} from "./NewThread"; + +function makeProject(overrides: Partial): NewThreadWidgetProject { + return { + title: "Project", + deepLink: "/new/draft?environmentId=env-1&projectId=proj-1&title=Project", + ...overrides, + }; +} + +function makeEnvironment(overrides: { + readonly widgetFamily: "systemSmall" | "systemMedium" | "systemLarge"; + readonly colorScheme?: "light" | "dark"; + readonly configuration?: Partial; +}) { + return { + date: new Date("2026-07-07T12:00:00.000Z"), + widgetFamily: overrides.widgetFamily, + colorScheme: overrides.colorScheme ?? "dark", + configuration: { pinnedProjects: "", showRecent: true, ...overrides.configuration }, + } as never; +} + +const smallEnvironment = makeEnvironment({ widgetFamily: "systemSmall" }); +const mediumEnvironment = makeEnvironment({ widgetFamily: "systemMedium" }); + +describe("NewThread widget layout", () => { + it("deep links the whole small widget to the new-task sheet", () => { + const layout = JSON.stringify(NewThread({ projects: [] }, smallEnvironment)); + expect(layout).toContain('"widgetURL":"t3code://new"'); + expect(layout).toContain("New Thread"); + }); + + it("renders the small call to action even when projects are present", () => { + const layout = JSON.stringify( + NewThread({ projects: [makeProject({ title: "t3code" })] }, smallEnvironment), + ); + expect(layout).not.toContain("t3code://new/draft"); + expect(layout).toContain('"widgetURL":"t3code://new"'); + }); + + it("tolerates missing props and configuration (WidgetKit placeholder renders with none)", () => { + const layout = JSON.stringify( + NewThread( + {} as never, + { + date: new Date("2026-07-07T12:00:00.000Z"), + widgetFamily: "systemMedium", + colorScheme: "light", + configuration: undefined, + } as never, + ), + ); + expect(layout).toContain('"widgetURL":"t3code://new"'); + }); + + it("links each medium row to its project's draft composer", () => { + const layout = JSON.stringify( + NewThread( + { + projects: [ + makeProject({ title: "t3code", deepLink: "/new/draft?projectId=a" }), + makeProject({ title: "marketing", deepLink: "/new/draft?projectId=b" }), + ], + }, + mediumEnvironment, + ), + ); + expect(layout).toContain("t3code"); + expect(layout).toContain("marketing"); + expect(layout).toContain('"destination":"t3code://new/draft?projectId=a"'); + expect(layout).toContain('"destination":"t3code://new/draft?projectId=b"'); + // Header link falls back to the plain new-task sheet. + expect(layout).toContain('"destination":"t3code://new"'); + }); + + it("renders at most three rows on medium and seven on large", () => { + const projects = [1, 2, 3, 4, 5, 6, 7, 8].map((n) => + makeProject({ title: `Project ${n}`, deepLink: `/new/draft?projectId=p${n}` }), + ); + const medium = JSON.stringify(NewThread({ projects }, mediumEnvironment)); + expect(medium).toContain("Project 3"); + expect(medium).not.toContain("Project 4"); + const large = JSON.stringify( + NewThread({ projects }, makeEnvironment({ widgetFamily: "systemLarge" })), + ); + expect(large).toContain("Project 7"); + expect(large).not.toContain("Project 8"); + }); + + it("pins configured projects ahead of recents, in pin order", () => { + const layout = JSON.stringify( + NewThread( + { + projects: [ + makeProject({ title: "recent" }), + makeProject({ title: "beta" }), + makeProject({ title: "alpha" }), + ], + }, + makeEnvironment({ + widgetFamily: "systemMedium", + configuration: { pinnedProjects: "Alpha, Beta" }, + }), + ), + ); + expect(layout.indexOf("alpha")).toBeLessThan(layout.indexOf("beta")); + expect(layout.indexOf("beta")).toBeLessThan(layout.indexOf("recent")); + // Pinned rows carry the pin glyph; the recent row keeps the folder. + expect(layout).toContain("pin.fill"); + expect(layout).toContain("folder"); + }); + + it("matches pins by case-insensitive prefix and skips unmatched pins", () => { + const layout = JSON.stringify( + NewThread( + { + projects: [makeProject({ title: "recent" }), makeProject({ title: "T3 Marketing Site" })], + }, + makeEnvironment({ + widgetFamily: "systemMedium", + configuration: { pinnedProjects: "t3 mark, no-such-project" }, + }), + ), + ); + expect(layout.indexOf("T3 Marketing Site")).toBeLessThan(layout.indexOf("recent")); + expect(layout).toContain("pin.fill"); + }); + + it("pins can surface projects beyond the visible row budget", () => { + const projects = [1, 2, 3, 4, 5].map((n) => makeProject({ title: `Project ${n}` })); + const layout = JSON.stringify( + NewThread( + { projects }, + makeEnvironment({ + widgetFamily: "systemMedium", + configuration: { pinnedProjects: "Project 5" }, + }), + ), + ); + expect(layout).toContain("Project 5"); + expect(layout).toContain("Project 1"); + expect(layout).toContain("Project 2"); + expect(layout).not.toContain("Project 3"); + }); + + it("hides recents when the fill toggle is off", () => { + const layout = JSON.stringify( + NewThread( + { projects: [makeProject({ title: "pinned-one" }), makeProject({ title: "recent" })] }, + makeEnvironment({ + widgetFamily: "systemMedium", + configuration: { pinnedProjects: "pinned-one", showRecent: false }, + }), + ), + ); + expect(layout).toContain("pinned-one"); + expect(layout).not.toContain("recent"); + }); + + it("falls back to the call to action when recents are off and no pin matches", () => { + const layout = JSON.stringify( + NewThread( + { projects: [makeProject({ title: "recent" })] }, + makeEnvironment({ + widgetFamily: "systemMedium", + configuration: { pinnedProjects: "gone", showRecent: false }, + }), + ), + ); + expect(layout).toContain('"widgetURL":"t3code://new"'); + expect(layout).not.toContain("recent"); + }); + + it("drops the link but keeps the row for unsafe deep links", () => { + const layout = JSON.stringify( + NewThread( + { projects: [makeProject({ title: "sneaky", deepLink: "//evil.example" })] }, + mediumEnvironment, + ), + ); + expect(layout).toContain("sneaky"); + expect(layout).not.toContain("evil.example"); + }); + + it("adapts the container background to the color scheme", () => { + const dark = JSON.stringify(NewThread({ projects: [] }, smallEnvironment)); + const light = JSON.stringify( + NewThread( + { projects: [] }, + makeEnvironment({ widgetFamily: "systemSmall", colorScheme: "light" }), + ), + ); + expect(dark).toContain('"containerBackground":"#0a0a0a"'); + expect(light).toContain('"containerBackground":"#ffffff"'); + }); +}); diff --git a/apps/mobile/src/widgets/NewThread.tsx b/apps/mobile/src/widgets/NewThread.tsx new file mode 100644 index 00000000000..2872298f530 --- /dev/null +++ b/apps/mobile/src/widgets/NewThread.tsx @@ -0,0 +1,203 @@ +import { HStack, Image, Link, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import type { ComponentProps, JSX } from "react"; +import { + containerBackground, + font, + foregroundStyle, + frame, + lineLimit, + padding, + resizable, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +export interface NewThreadWidgetProject { + readonly title: string; + /** App-relative path to the prefilled draft composer, e.g. "/new/draft?environmentId=…". */ + readonly deepLink: string; +} + +export interface NewThreadWidgetProps { + /** + * Candidate projects, most recently active first. The medium/large widgets + * render a slice of these as one-tap shortcuts into the draft composer. + * More are synced than any family displays: pinned-project matching happens + * here in the widget (the configuration is per widget instance, so the app + * cannot precompute it), and a pin should resolve even when its project has + * fallen out of the top displayed rows. + * Optional because WidgetKit renders placeholder/gallery entries with no + * props before the app has ever written a timeline. + */ + readonly projects?: ReadonlyArray; +} + +/** Mirrors the `configuration.parameters` in app.config.ts. */ +export interface NewThreadWidgetConfiguration { + /** Comma-separated project titles to pin ahead of recent projects. */ + readonly pinnedProjects: string; + /** Whether to fill the remaining rows with recently active projects. */ + readonly showRecent: boolean; +} + +// This function is serialized into the widget extension's JS bundle, so it +// must stay self-contained: no references to module-scope helpers, only the +// imported view/modifier factories. +export function NewThread( + props: NewThreadWidgetProps, + environment: WidgetEnvironment, +): JSX.Element { + "widget"; + + // Semantic label colors adapt to whatever material the OS renders the + // widget on (light/dark home screen, tinted iOS 18 mode). + const primaryForeground = "primary"; + const secondaryForeground = "secondary"; + // Home-screen widgets must supply their own container background on iOS 17+; + // match the app's splash background per scheme. + const containerColor = environment.colorScheme === "dark" ? "#0a0a0a" : "#ffffff"; + + // Any registered scheme variant routes back to this app; taps are delivered + // to the widget's containing app, so the prod scheme is safe for all builds. + const newThreadUrl = "t3code://new"; + const toWidgetUrl = (deepLink: string): string | null => + deepLink.startsWith("/") && !deepLink.startsWith("//") ? `t3code://${deepLink.slice(1)}` : null; + + // SF Symbols and the logo ignore frame/foregroundStyle applied directly to + // the image; size + tint them through a container the resizable image fills. + type SFName = NonNullable["systemName"]>; + const renderGlyph = (systemName: SFName, size: number, color: string) => ( + + + + ); + // The 3:2 frame matches the T3 mark's aspect ratio so it never distorts. + const renderLogo = (height: number, color: string) => ( + + + + ); + + const available = props.projects ?? []; + const configuration = environment.configuration; + + // Pins are typed titles (see app.config.ts for why there is no picker). + // Match case-insensitively, exact title first and then prefix, preserving + // the user's pin order. Unmatched pins (typo, deleted project) are skipped. + const pinnedQueries = ( + typeof configuration?.pinnedProjects === "string" ? configuration.pinnedProjects : "" + ) + .split(",") + .map((title) => title.trim().toLowerCase()) + .filter((title) => title.length > 0); + const pinned: NewThreadWidgetProject[] = []; + for (const query of pinnedQueries) { + const match = + available.find( + (project) => !pinned.includes(project) && project.title.toLowerCase() === query, + ) ?? + available.find( + (project) => !pinned.includes(project) && project.title.toLowerCase().startsWith(query), + ); + if (match) { + pinned.push(match); + } + } + + // Pinned first, then latest activity — unless recents are configured off. + const rows = [...pinned]; + if (configuration?.showRecent !== false) { + for (const project of available) { + if (!rows.includes(project)) { + rows.push(project); + } + } + } + const rowLimit = environment.widgetFamily === "systemLarge" ? 7 : 3; + const visible = rows.slice(0, rowLimit); + + // The whole small widget is one tap target; the medium/large widgets add + // per-row links, with the root widgetURL as the fallback for untargeted areas. + const rootModifiers = [containerBackground(containerColor, "widget"), widgetURL(newThreadUrl)]; + + // Small family — and the larger families with nothing to list (no sync yet, + // or recents off with no pin matched) — render a single centered call to action. + if (environment.widgetFamily === "systemSmall" || visible.length === 0) { + return ( + + + {renderLogo(20, primaryForeground)} + + {renderGlyph("square.and.pencil", 12, secondaryForeground)} + + New Thread + + + + + ); + } + + // Project shortcut row: tapping opens the draft composer with that project + // preselected. Rows whose deep link fails the safety check still render, + // but fall through to the root's plain new-thread tap target. + const renderProjectRow = (project: NewThreadWidgetProject, isPinned: boolean) => { + const url = toWidgetUrl(project.deepLink); + const row = ( + + {renderGlyph(isPinned ? "pin.fill" : "folder", 11, secondaryForeground)} + + {project.title} + + + {renderGlyph("chevron.forward", 9, secondaryForeground)} + + ); + return url ? {row} : row; + }; + + // Pinned rows always precede recents, so index < pinned.length marks them. + const renderRow = (index: number) => { + const project = visible[index]; + return project ? renderProjectRow(project, index < pinned.length) : null; + }; + + return ( + + + + {renderLogo(15, primaryForeground)} + + New Thread + + + {renderGlyph("square.and.pencil", 14, secondaryForeground)} + + + + {renderRow(0)} + {renderRow(1)} + {renderRow(2)} + {renderRow(3)} + {renderRow(4)} + {renderRow(5)} + {renderRow(6)} + + + ); +} + +export default createWidget( + "NewThread", + NewThread, +);