Add iOS New Thread home screen widget#3786
Conversation
- Register configurable NewThread widget for iOS - Sync recent project shortcuts and deep links into draft threads - Cover widget ordering, pinning, and layout behavior
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
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.
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]); |
There was a problem hiding this comment.
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.
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) => ({ |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 19d8807. Configure here.
| ) ?? | ||
| available.find( | ||
| (project) => !pinned.includes(project) && project.title.toLowerCase().startsWith(query), | ||
| ); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 19d8807. Configure here.
ApprovabilityVerdict: 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. |



Summary
NewThreadhome screen widget with small, medium, and large layouts.Testing
vp testfornew-thread-widgetandNewThreadtest coverage.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
updatedAtper environment+project, with encoding-safe/new/draft?…links aligned withNewTaskSheetlinking) and pushes it viauseNewThreadWidgetSyncon iOS, deduping writes to avoid WidgetKit reload throttling. The widget layout pins titles case-insensitively, shows 3 or 7 shortcut rows, usest3code://newfor 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
📊 Macroscope summarized 19d8807. 5 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.