Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SymbolView } from "./AppSymbol";
import { Image } from "expo-image";
import { useLayoutEffect, useMemo, useState } from "react";
import { memo, useLayoutEffect, useMemo, useState } from "react";
import { View } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
import {
Expand All @@ -23,7 +23,7 @@ import {
const EMPTY_FAVICON_URL = Atom.make<string | null>(null);

/* ─── Component ──────────────────────────────────────────────────────── */
export function ProjectFavicon(props: {
export const ProjectFavicon = memo(function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
readonly open?: boolean;
readonly size?: number;
Expand All @@ -41,15 +41,29 @@ export function ProjectFavicon(props: {
faviconPath: props.faviconPath,
}),
);
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
const renderableFaviconUrl = useMemo(
() => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl),
[faviconUrl],
);
// Inline images are self-contained; remote URLs key on their revision so signed-token
// rotation reuses the disk cache while a changed icon starts from the loading state.
const cacheKey =
renderableFaviconUrl && props.workspaceRoot
? renderableFaviconUrl.startsWith("data:")
? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
: getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
: null;
const cacheKey = useMemo(
() =>
renderableFaviconUrl && props.workspaceRoot
? renderableFaviconUrl.startsWith("data:")
? getProjectFaviconResourceKey(
props.environmentId,
props.workspaceRoot,
props.faviconPath,
)
: getProjectFaviconCacheKey(
props.environmentId,
props.workspaceRoot,
renderableFaviconUrl,
)
: null,
[renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath],
);

return (
<ProjectFaviconImage
Expand All @@ -61,7 +75,7 @@ export function ProjectFavicon(props: {
size={size}
/>
);
}
});

function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
Expand Down
30 changes: 18 additions & 12 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
import { useNavigation } from "@react-navigation/native";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform, useWindowDimensions } from "react-native";

import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";

import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects, useNavigationThreadShells } from "../../state/entities";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
Expand Down Expand Up @@ -35,6 +37,20 @@ export function HomeRouteScreen() {
const navigation = useNavigation();
const [searchQuery, setSearchQuery] = useState("");
const handleSelectThread = useHomeThreadSelection();
const handleNewThreadOnBranch = useCallback(
(thread: EnvironmentThreadShell) => {
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
params: {
environmentId: String(thread.environmentId),
projectId: String(thread.projectId),
branch: thread.branch,
worktreePath: thread.worktreePath,
},
});
},
[navigation],
);

useEffect(() => {
void checkForAppUpdateOnLaunch();
Expand Down Expand Up @@ -216,17 +232,7 @@ export function HomeRouteScreen() {
onSelectThread={handleSelectThread}
onSelectPendingTask={openPendingTask}
onDeletePendingTask={confirmDeletePendingTask}
onNewThreadOnBranch={(thread) => {
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
params: {
environmentId: String(thread.environmentId),
projectId: String(thread.projectId),
branch: thread.branch,
worktreePath: thread.worktreePath,
},
});
}}
onNewThreadOnBranch={handleNewThreadOnBranch}
onNewThreadInProject={(project) => {
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
Expand Down
16 changes: 1 addition & 15 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
import type { ThreadMoveDestination } from "../threads/threadOrder";
import { createThreadMovePlanner } from "../threads/threadOrder";
import {
Expand Down Expand Up @@ -60,7 +59,6 @@ import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
resolveThreadListV2ProviderDrivers,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
Expand Down Expand Up @@ -844,13 +842,6 @@ export function HomeScreen(props: HomeScreenProps) {
const thread = item.item.thread;
const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
const movedId = `${thread.environmentId}:${thread.id}`;
const provider = serverConfigs
.get(thread.environmentId)
?.providers.find(
(candidate) =>
candidate.instanceId ===
(thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
);
return (
<ThreadListV2Row
onNewThreadOnBranch={props.onNewThreadOnBranch}
Expand All @@ -868,12 +859,7 @@ export function HomeScreen(props: HomeScreenProps) {
projectTitle={v2ProjectTitleByProjectKey.get(
scopedProjectKey(thread.environmentId, thread.projectId),
)}
providerDrivers={resolveThreadListV2ProviderDrivers(
thread,
serverConfigs.get(thread.environmentId)?.providers,
)}
providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
providerIconUrl={provider?.iconUrl}
providers={serverConfigs.get(thread.environmentId)?.providers}
environmentLabel={
Object.keys(props.savedConnectionsById).length > 1
? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
Expand Down
16 changes: 1 addition & 15 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { resolveThreadProviderInstance } from "./thread-provider-instance";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { createThreadMovePlanner } from "./threadOrder";
import type {
Expand Down Expand Up @@ -85,7 +84,6 @@ import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
resolveThreadListV2ProviderDrivers,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
Expand Down Expand Up @@ -900,13 +898,6 @@ function ThreadNavigationSidebarPane(
: threadMovePlanners.active;
const movedId = `${thread.environmentId}:${thread.id}`;
const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
const provider = serverConfigs
.get(thread.environmentId)
?.providers.find(
(candidate) =>
candidate.instanceId ===
(thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
);
return (
<ThreadListV2Row
onNewThreadOnBranch={props.onNewThreadOnBranch}
Expand All @@ -919,12 +910,7 @@ function ThreadNavigationSidebarPane(
snoozeWakeLabelText={item.snoozeWakeLabelText}
project={projectByKey.get(scopeKey) ?? null}
projectTitle={projectTitleByProjectKey.get(scopeKey)}
providerDrivers={resolveThreadListV2ProviderDrivers(
thread,
serverConfigs.get(thread.environmentId)?.providers,
)}
providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
providerIconUrl={provider?.iconUrl}
providers={serverConfigs.get(thread.environmentId)?.providers}
environmentLabel={
Object.keys(savedConnectionsById).length > 1
? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
Expand Down
40 changes: 25 additions & 15 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ThreadRowProviderInstance } from "./thread-provider-instance";
import { resolveThreadProviderInstance } from "./thread-provider-instance";
import { RowPressable } from "../../components/RowPressable";
import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
Expand All @@ -10,7 +10,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import type { EnvironmentMachineKind, ServerConfig } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
import type { MenuAction } from "@react-native-menu/menu";
Expand All @@ -36,6 +36,7 @@ import {
resolveThreadListV2SnoozeMenuSelection,
threadHasUnseenCompletion,
resolveThreadListV2Status,
resolveThreadListV2ProviderDrivers,
resolveThreadListV2SwipeActions,
type ThreadListV2Status,
} from "./threadListV2";
Expand Down Expand Up @@ -358,12 +359,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly snoozePresetMinute: string;
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
/** Provider drivers back to front: earlier owners first, current last.
Empty when the environment's config has not resolved yet. */
readonly providerDrivers: ReadonlyArray<string>;
/** Account-aware presentation for the current provider owner. */
readonly providerInstance: ThreadRowProviderInstance | null;
readonly providerIconUrl?: string | null;
/** Keep the environment's provider array stable across unrelated list updates. */
readonly providers: ServerConfig["providers"] | undefined;
/** Which machine hosts the thread. Null when only one environment is
connected — repeating the same label on every row is noise. Mirrors
the web sidebar's remote-environment cloud icon, but as text since
Expand Down Expand Up @@ -445,6 +442,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;

const { providerDrivers, providerInstance, providerIconUrl } = useMemo(() => {
const provider = props.providers?.find(
(candidate) =>
candidate.instanceId ===
(thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId),
);
return {
providerDrivers: resolveThreadListV2ProviderDrivers(thread, props.providers),
providerInstance: resolveThreadProviderInstance(props.providers, thread),
providerIconUrl: provider?.iconUrl,
};
}, [thread, props.providers]);

const pr = useThreadPr(thread);

const { materialYouStyleLayoutActive } = useAppearancePreferences();
Expand Down Expand Up @@ -930,23 +940,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</Text>
</View>
) : null}
{props.providerInstance ? (
{providerInstance ? (
// Earlier owners peek out behind the current provider so a
// handed-off thread shows where it has been. The current owner
// keeps its account badge so same-driver instances stay distinct.
<View className="flex-row items-center">
{props.providerDrivers.slice(0, -1).map((driver, index) => (
{providerDrivers.slice(0, -1).map((driver, index) => (
<View key={`${driver}:${index}`} className="-mr-1 opacity-30">
<ProviderIcon provider={driver} size={12} />
</View>
))}
<ProviderInstanceIcon
iconUrl={props.providerIconUrl}
provider={props.providerInstance.driverKind}
iconUrl={providerIconUrl}
provider={providerInstance.driverKind}
size={14}
displayName={props.providerInstance.displayName}
accentColor={props.providerInstance.accentColor}
showBadge={props.providerInstance.showBadge}
displayName={providerInstance.displayName}
accentColor={providerInstance.accentColor}
showBadge={providerInstance.showBadge}
surfaceColor={providerIconSurfaceColor}
/>
</View>
Expand Down
29 changes: 22 additions & 7 deletions apps/mobile/src/features/threads/thread-provider-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ describe("resolveThreadProviderInstance", () => {
const threadA = makeThread(environmentA, "codex");
const threadB = makeThread(environmentB, "codex");

expect(resolveThreadProviderInstance(serverConfigs, threadA)?.accentColor).toBe("#ff8800");
expect(resolveThreadProviderInstance(serverConfigs, threadB)?.accentColor).toBeUndefined();
expect(
resolveThreadProviderInstance(serverConfigs.get(environmentA)?.providers, threadA)
?.accentColor,
).toBe("#ff8800");
expect(
resolveThreadProviderInstance(serverConfigs.get(environmentB)?.providers, threadB)
?.accentColor,
).toBeUndefined();
});

it("labels a custom instance by its id so its initials differ from the default", () => {
Expand All @@ -78,11 +84,16 @@ describe("resolveThreadProviderInstance", () => {
]);

expect(
resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex"))?.displayName,
resolveThreadProviderInstance(
serverConfigs.get(environmentId)?.providers,
makeThread(environmentId, "codex"),
)?.displayName,
).toBe("Codex");
expect(
resolveThreadProviderInstance(serverConfigs, makeThread(environmentId, "codex_personal"))
?.displayName,
resolveThreadProviderInstance(
serverConfigs.get(environmentId)?.providers,
makeThread(environmentId, "codex_personal"),
)?.displayName,
).toBe("Codex Personal");
});

Expand Down Expand Up @@ -110,7 +121,9 @@ describe("resolveThreadProviderInstance", () => {
},
};

expect(resolveThreadProviderInstance(serverConfigs, thread)).toMatchObject({
expect(
resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread),
).toMatchObject({
driverKind: "codex",
displayName: "Codex Work",
showBadge: true,
Expand All @@ -124,6 +137,8 @@ describe("resolveThreadProviderInstance", () => {
]);
const thread = makeThread(environmentId, "codex");

expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
expect(
resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread)?.showBadge,
).toBe(false);
});
});
6 changes: 3 additions & 3 deletions apps/mobile/src/features/threads/thread-provider-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
resolveProviderInstanceDisplayName,
shouldShowInstanceBadge,
} from "@t3tools/client-runtime/state/provider-instance-display";
import type { EnvironmentId, ProviderDriverKind, ServerConfig } from "@t3tools/contracts";
import type { ProviderDriverKind, ServerConfig } from "@t3tools/contracts";

/** What a thread row needs to draw the provider glyph and its account badge. */
export interface ThreadRowProviderInstance {
Expand All @@ -20,10 +20,10 @@ export interface ThreadRowProviderInstance {
* names a different account on every server.
*/
export function resolveThreadProviderInstance(
serverConfigs: ReadonlyMap<EnvironmentId, ServerConfig>,
providers: ServerConfig["providers"] | undefined,
thread: EnvironmentThreadShell,
): ThreadRowProviderInstance | null {
const providers = serverConfigs.get(thread.environmentId)?.providers ?? [];
if (providers === undefined) return null;
const instanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
const snapshot = providers.find((provider) => provider.instanceId === instanceId);
if (!snapshot) return null;
Expand Down
Loading