Skip to content

Add iOS New Thread home screen widget#3786

Open
juliusmarminge wants to merge 1 commit into
mainfrom
t3code/ios-homescreen-thread-widgets
Open

Add iOS New Thread home screen widget#3786
juliusmarminge wants to merge 1 commit into
mainfrom
t3code/ios-homescreen-thread-widgets

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jul 7, 2026

Copy link
Copy Markdown
Member

Summary

  • Add an iOS NewThread home screen widget with small, medium, and large layouts.
  • Sync recently active projects into the widget and deep link shortcuts into the new thread draft flow.
  • Support per-widget pinned project text configuration and optional recent-project filling.
  • Add tests for widget payload ordering, deep link encoding, layout behavior, pin matching, and unsafe links.

Testing

  • vp test for new-thread-widget and NewThread test coverage.
  • Not run: vp check, vp run typecheck, vp run lint:mobile.

Note

Medium Risk
New widget extension and deep-link entry points into the new-task flow, but behavior is iOS-only, mirrors existing expo-widgets patterns, and validates widget URLs before linking.

Overview
Adds a New Thread iOS home screen widget (small / medium / large) so users can open the new-task flow or jump straight into a project’s draft composer from the Home Screen.

Expo config registers the widget with per-instance Edit Widget options: comma-separated pinned project titles and a fill with recent projects toggle (text pins because the project list is dynamic at runtime).

The main app builds a snapshot of up to 20 recently active projects (thread updatedAt per environment+project, with encoding-safe /new/draft?… links aligned with NewTaskSheet linking) and pushes it via useNewThreadWidgetSync on iOS, deduping writes to avoid WidgetKit reload throttling. The widget layout pins titles case-insensitively, shows 3 or 7 shortcut rows, uses t3code://new for the small CTA, and strips unsafe deep links.

Unit tests cover payload ordering, sync limits, layout, pins, and link safety.

Reviewed by Cursor Bugbot for commit 19d8807. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add iOS New Thread home screen widget with project shortcuts

  • Adds a new WidgetKit widget (NewThread.tsx) that renders a tap target to open the new-thread sheet, with per-row deep links to prefilled draft composers for recent or pinned projects.
  • Small family shows a single CTA; medium and large families show up to 3 and 7 project rows respectively, with pinned projects surfaced first via case-insensitive prefix matching.
  • A new hook (use-new-thread-widget-sync.ts) computes widget props from the current projects and thread shells, sorts by latest thread activity, caps at 20 projects, and writes a snapshot to WidgetCenter on iOS, skipping writes when props are unchanged.
  • Widget background adapts to light/dark color scheme; deep links are URL-encoded and validated before use as tap targets.
📊 Macroscope summarized 19d8807. 5 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

- Register configurable NewThread widget for iOS
- Sync recent project shortcuts and deep links into draft threads
- Cover widget ordering, pinning, and layout behavior
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1fe57430-15f7-4b15-8351-e2109c366578

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/ios-homescreen-thread-widgets

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 7, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Widget sync won't retry
    • Scheduled a cleared retry timer on snapshot write failure so sync recovers even when projects/threads references stay unchanged.
  • ✅ Fixed: Pins omit low-activity projects
    • Removed the 20-project sync cap so the full activity-ordered project set is available for pin matching.
  • ✅ Fixed: Duplicate titles match wrong project
    • Disambiguate duplicate titles with environment labels in the synced payload and skip ambiguous pin matches that would resolve to more than one project.

Create PR

Or push these changes by commenting:

@cursor push 352e65d361
Preview (352e65d361)
diff --git a/apps/mobile/src/features/threads/new-thread-widget.test.ts b/apps/mobile/src/features/threads/new-thread-widget.test.ts
--- a/apps/mobile/src/features/threads/new-thread-widget.test.ts
+++ b/apps/mobile/src/features/threads/new-thread-widget.test.ts
@@ -4,10 +4,7 @@
   EnvironmentThreadShell,
 } from "@t3tools/client-runtime/state/shell";
 
-import {
-  makeNewThreadWidgetProps,
-  NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT,
-} from "./new-thread-widget";
+import { makeNewThreadWidgetProps } from "./new-thread-widget";
 
 function makeProject(overrides: {
   readonly id: string;
@@ -81,9 +78,10 @@
     expect(props.projects?.map((project) => project.title)).toEqual(["two", "one"]);
   });
 
-  it("caps the payload at the widget's sync limit", () => {
+  it("syncs every project so low-activity pins can still resolve", () => {
+    const count = 25;
     const props = makeNewThreadWidgetProps(
-      Array.from({ length: NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT + 5 }, (_, n) =>
+      Array.from({ length: count }, (_, n) =>
         makeProject({
           id: `p${n}`,
           updatedAt: `2026-06-${String(n + 1).padStart(2, "0")}T00:00:00.000Z`,
@@ -91,10 +89,56 @@
       ),
       [],
     );
-    expect(props.projects).toHaveLength(NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT);
-    expect(props.projects?.[0]?.title).toBe(`p${NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT + 4}`);
+    expect(props.projects).toHaveLength(count);
+    expect(props.projects?.[0]?.title).toBe(`p${count - 1}`);
+    expect(props.projects?.[count - 1]?.title).toBe("p0");
   });
 
+  it("disambiguates duplicate titles with environment labels", () => {
+    const props = makeNewThreadWidgetProps(
+      [
+        makeProject({
+          id: "proj-a",
+          environmentId: "env-1",
+          title: "t3code",
+          updatedAt: "2026-07-07T00:00:00.000Z",
+        }),
+        makeProject({
+          id: "proj-b",
+          environmentId: "env-2",
+          title: "t3code",
+          updatedAt: "2026-07-01T00:00:00.000Z",
+        }),
+      ],
+      [],
+      new Map([
+        ["env-1", "Laptop"],
+        ["env-2", "Desktop"],
+      ]),
+    );
+    expect(props.projects?.map((project) => project.title)).toEqual([
+      "t3code (Laptop)",
+      "t3code (Desktop)",
+    ]);
+    expect(props.projects?.[0]?.deepLink).toContain("environmentId=env-1");
+    expect(props.projects?.[0]?.deepLink).toContain("title=t3code");
+    expect(props.projects?.[1]?.deepLink).toContain("environmentId=env-2");
+  });
+
+  it("falls back to environment id when labels are missing for duplicates", () => {
+    const props = makeNewThreadWidgetProps(
+      [
+        makeProject({ id: "a", environmentId: "env-1", title: "shared" }),
+        makeProject({ id: "b", environmentId: "env-2", title: "shared" }),
+      ],
+      [],
+    );
+    expect(props.projects?.map((project) => project.title)).toEqual([
+      "shared (env-1)",
+      "shared (env-2)",
+    ]);
+  });
+
   it("URL-encodes deep link parameters", () => {
     const props = makeNewThreadWidgetProps(
       [makeProject({ id: "p 1", environmentId: "env&1", title: "T3 & friends" })],

diff --git a/apps/mobile/src/features/threads/new-thread-widget.ts b/apps/mobile/src/features/threads/new-thread-widget.ts
--- a/apps/mobile/src/features/threads/new-thread-widget.ts
+++ b/apps/mobile/src/features/threads/new-thread-widget.ts
@@ -5,24 +5,26 @@
 
 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.
+ * Builds the home-screen widget payload: all projects ordered by recent
+ * activity, each with a deep link into the new-task draft composer.
  *
+ * The full set is synced (not just the visible rows) so pinned-project matching
+ * inside the widget can resolve a deliberately pinned low-activity project.
+ * Medium shows 3 rows and large 7; the widget layout slices after pin matching.
+ *
  * "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.
+ *
+ * When two projects share a title, the display title is disambiguated with the
+ * environment label (falling back to environment id, then project id) so pin
+ * matching and the deep link target the intended workspace.
  */
 export function makeNewThreadWidgetProps(
   projects: ReadonlyArray<EnvironmentProject>,
   threads: ReadonlyArray<EnvironmentThreadShell>,
+  environmentLabels: ReadonlyMap<string, string> = new Map(),
 ): NewThreadWidgetProps {
   const latestThreadActivity = new Map<string, string>();
   for (const thread of threads) {
@@ -44,12 +46,14 @@
   };
 
   const ordered = [...projects].sort((a, b) => lastActivity(b).localeCompare(lastActivity(a)));
+  const displayTitles = disambiguateProjectTitles(ordered, environmentLabels);
 
   return {
-    projects: ordered.slice(0, NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT).map((project) => ({
-      title: project.title,
+    projects: ordered.map((project, index) => ({
+      title: displayTitles[index] ?? project.title,
       // Matches the NewTaskSheet > NewTaskDraft linking config in Stack.tsx;
-      // the widget layout prefixes the scheme after a safety check.
+      // the widget layout prefixes the scheme after a safety check. Keep the
+      // original project title in the query — not the disambiguated display.
       deepLink:
         `/new/draft?environmentId=${encodeURIComponent(String(project.environmentId))}` +
         `&projectId=${encodeURIComponent(String(project.id))}` +
@@ -58,6 +62,39 @@
   };
 }
 
+function disambiguateProjectTitles(
+  projects: ReadonlyArray<EnvironmentProject>,
+  environmentLabels: ReadonlyMap<string, string>,
+): string[] {
+  const titleCounts = countBy((project) => project.title.toLowerCase(), projects);
+  const withEnvironment = projects.map((project) => {
+    if ((titleCounts.get(project.title.toLowerCase()) ?? 0) <= 1) {
+      return project.title;
+    }
+    const envLabel =
+      environmentLabels.get(String(project.environmentId)) ?? String(project.environmentId);
+    return `${project.title} (${envLabel})`;
+  });
+
+  const envTitleCounts = countBy((title) => title.toLowerCase(), withEnvironment);
+  return withEnvironment.map((title, index) => {
+    if ((envTitleCounts.get(title.toLowerCase()) ?? 0) <= 1) {
+      return title;
+    }
+    const project = projects[index];
+    return project ? `${title} · ${project.id}` : title;
+  });
+}
+
+function countBy<T>(keyOf: (value: T) => string, values: ReadonlyArray<T>): Map<string, number> {
+  const counts = new Map<string, number>();
+  for (const value of values) {
+    const key = keyOf(value);
+    counts.set(key, (counts.get(key) ?? 0) + 1);
+  }
+  return counts;
+}
+
 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
--- a/apps/mobile/src/features/threads/use-new-thread-widget-sync.ts
+++ b/apps/mobile/src/features/threads/use-new-thread-widget-sync.ts
@@ -1,7 +1,8 @@
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
 import { Platform } from "react-native";
 
 import { useProjects, useThreadShells } from "../../state/entities";
+import { useEnvironments } from "../../state/environments";
 import NewThreadWidget from "../../widgets/NewThread";
 import { makeNewThreadWidgetProps } from "./new-thread-widget";
 
@@ -10,6 +11,9 @@
 // in a WidgetCenter reload, and iOS throttles widgets that reload too often.
 let lastSyncedSignature: string | null = null;
 
+/** Delay before retrying a failed snapshot write while entity data is unchanged. */
+const SNAPSHOT_RETRY_MS = 5_000;
+
 /**
  * Keeps the NewThread home-screen widget's project shortcuts in sync with the
  * workspace. Writing the snapshot also persists the widget layout to the
@@ -19,25 +23,53 @@
 export function useNewThreadWidgetSync(): void {
   const projects = useProjects();
   const threads = useThreadShells();
+  const { environments } = useEnvironments();
+  const environmentLabels = useMemo(() => {
+    const labels = new Map<string, string>();
+    for (const environment of environments) {
+      labels.set(String(environment.environmentId), environment.label);
+    }
+    return labels;
+  }, [environments]);
 
   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);
+
+    let cancelled = false;
+    let retryTimer: ReturnType<typeof setTimeout> | null = null;
+
+    const sync = () => {
+      if (cancelled) {
+        return;
       }
-    }
-  }, [projects, threads]);
+      const props = makeNewThreadWidgetProps(projects, threads, environmentLabels);
+      const signature = JSON.stringify(props);
+      if (signature === lastSyncedSignature) {
+        return;
+      }
+      try {
+        NewThreadWidget.updateSnapshot(props);
+        lastSyncedSignature = signature;
+      } catch (error) {
+        // Do not treat the write as succeeded. Schedule a retry so a transient
+        // app-group failure still recovers when projects/threads stay the same.
+        lastSyncedSignature = null;
+        if (__DEV__) {
+          console.warn("[new-thread-widget] snapshot update failed", error);
+        }
+        retryTimer = setTimeout(sync, SNAPSHOT_RETRY_MS);
+      }
+    };
+
+    sync();
+
+    return () => {
+      cancelled = true;
+      if (retryTimer !== null) {
+        clearTimeout(retryTimer);
+      }
+    };
+  }, [projects, threads, environmentLabels]);
 }

diff --git a/apps/mobile/src/widgets/NewThread.test.ts b/apps/mobile/src/widgets/NewThread.test.ts
--- a/apps/mobile/src/widgets/NewThread.test.ts
+++ b/apps/mobile/src/widgets/NewThread.test.ts
@@ -174,6 +174,62 @@
     expect(layout).not.toContain("Project 3");
   });
 
+  it("skips ambiguous pin titles instead of linking the first match", () => {
+    const layout = JSON.stringify(
+      NewThread(
+        {
+          projects: [
+            makeProject({
+              title: "t3code (Laptop)",
+              deepLink: "/new/draft?environmentId=env-1&projectId=a&title=t3code",
+            }),
+            makeProject({
+              title: "t3code (Desktop)",
+              deepLink: "/new/draft?environmentId=env-2&projectId=b&title=t3code",
+            }),
+            makeProject({ title: "recent", deepLink: "/new/draft?projectId=recent" }),
+          ],
+        },
+        makeEnvironment({
+          widgetFamily: "systemMedium",
+          configuration: { pinnedProjects: "t3code", showRecent: false },
+        }),
+      ),
+    );
+    // Ambiguous bare title must not deep-link either duplicate.
+    expect(layout).not.toContain("environmentId=env-1");
+    expect(layout).not.toContain("environmentId=env-2");
+    expect(layout).toContain('"widgetURL":"t3code://new"');
+  });
+
+  it("pins a disambiguated title to the intended environment", () => {
+    const layout = JSON.stringify(
+      NewThread(
+        {
+          projects: [
+            makeProject({
+              title: "t3code (Laptop)",
+              deepLink: "/new/draft?environmentId=env-1&projectId=a&title=t3code",
+            }),
+            makeProject({
+              title: "t3code (Desktop)",
+              deepLink: "/new/draft?environmentId=env-2&projectId=b&title=t3code",
+            }),
+          ],
+        },
+        makeEnvironment({
+          widgetFamily: "systemMedium",
+          configuration: { pinnedProjects: "t3code (Desktop)", showRecent: false },
+        }),
+      ),
+    );
+    expect(layout).toContain("t3code (Desktop)");
+    expect(layout).toContain(
+      '"destination":"t3code://new/draft?environmentId=env-2&projectId=b&title=t3code"',
+    );
+    expect(layout).not.toContain("environmentId=env-1");
+  });
+
   it("hides recents when the fill toggle is off", () => {
     const layout = JSON.stringify(
       NewThread(

diff --git a/apps/mobile/src/widgets/NewThread.tsx b/apps/mobile/src/widgets/NewThread.tsx
--- a/apps/mobile/src/widgets/NewThread.tsx
+++ b/apps/mobile/src/widgets/NewThread.tsx
@@ -22,10 +22,10 @@
   /**
    * 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.
+   * The full workspace set is synced (not just the visible rows): 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.
    */
@@ -83,7 +83,9 @@
 
   // 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.
+  // the user's pin order. A query that matches more than one remaining project
+  // is skipped (no ambiguous deep link) rather than picking the first by
+  // activity order. Unmatched pins (typo, deleted project) are skipped.
   const pinnedQueries = (
     typeof configuration?.pinnedProjects === "string" ? configuration.pinnedProjects : ""
   )
@@ -92,13 +94,19 @@
     .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),
+    const unused = available.filter((project) => !pinned.includes(project));
+    const exactMatches = unused.filter((project) => project.title.toLowerCase() === query);
+    let match: NewThreadWidgetProject | undefined;
+    if (exactMatches.length === 1) {
+      match = exactMatches[0];
+    } else if (exactMatches.length === 0) {
+      const prefixMatches = unused.filter((project) =>
+        project.title.toLowerCase().startsWith(query),
       );
+      if (prefixMatches.length === 1) {
+        match = prefixMatches[0];
+      }
+    }
     if (match) {
       pinned.push(match);
     }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 19d8807. Configure here.

console.warn("[new-thread-widget] snapshot update failed", error);
}
}
}, [projects, threads]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widget sync won't retry

Medium Severity

When NewThreadWidget.updateSnapshot throws, the hook clears lastSyncedSignature but the effect only depends on projects and threads. If those references stay the same after a failed write, the effect does not run again, so the widget can stay on stale or empty data until project or thread data changes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 19d8807. Configure here.

const ordered = [...projects].sort((a, b) => lastActivity(b).localeCompare(lastActivity(a)));

return {
projects: ordered.slice(0, NEW_THREAD_WIDGET_PROJECT_SYNC_LIMIT).map((project) => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pins omit low-activity projects

Medium Severity

Pinned project names are resolved only against the widget timeline payload, which makeNewThreadWidgetProps caps at the 20 most recently active projects. A pinned title whose project ranks below that cutoff is never in props.projects, so the widget skips it with no row or fallback beyond the generic new-thread action.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 19d8807. Configure here.

) ??
available.find(
(project) => !pinned.includes(project) && project.title.toLowerCase().startsWith(query),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate titles match wrong project

Medium Severity

Widget pins match projects by display title only. When two environments expose the same title, available.find returns whichever duplicate appears first in the activity-sorted payload, so the row deep link may target a different environmentId and projectId than the user intended.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 19d8807. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a new iOS home screen widget feature with significant new user-facing behavior and multiple new components. New features of this scope require human review. Additionally, there are 3 unresolved medium-severity review comments identifying potential edge case bugs.

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant