Skip to content
Closed
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
75 changes: 56 additions & 19 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ import {
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
import { shortcutLabelForCommand } from "../../keybindings";
import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";
import {
hasArchivedThreadSnapshotFailure,
projectDeleteCommandInput,
projectThreadCount,
} from "../../lib/projectRemoval";
import { readLocalApi } from "../../localApi";
import {
buildProjectScript,
Expand Down Expand Up @@ -308,6 +314,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const serverProviders = useAtomValue(primaryServerProvidersAtom);
const threads = useThreadShells();
const groupEnvironmentIds = useMemo(
() => [...new Set(group.memberProjects.map((member) => member.environmentId))],
[group.memberProjects],
);
const {
snapshots: archivedThreadSnapshots,
error: archivedThreadsError,
failedEnvironmentIds: archivedThreadsFailedEnvironmentIds,
isLoading: archivedThreadsLoading,
} = useArchivedThreadSnapshots(groupEnvironmentIds);
const archivedThreads = useMemo(
() =>
archivedThreadSnapshots.flatMap(({ environmentId, snapshot }) =>
snapshot.threads.map((thread) => ({ ...thread, environmentId })),
),
[archivedThreadSnapshots],
);
const knownThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]);
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
Expand Down Expand Up @@ -339,12 +363,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {

const threadCountByMember = useMemo(() => {
const counts = new Map<string, number>();
for (const thread of threads) {
for (const thread of knownThreads) {
const key = `${thread.environmentId}:${thread.projectId}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}, [threads]);
}, [knownThreads]);
const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
const error = squashAtomCommandFailure(result);
Expand Down Expand Up @@ -671,8 +695,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
const api = readLocalApi();
if (!api) return;

if (
archivedThreadsError &&
hasArchivedThreadSnapshotFailure(members, archivedThreadsFailedEnvironmentIds)
) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not check archived threads",
description: archivedThreadsError,
}),
);
return;
}

const memberKeys = new Set(members.map(memberKey));
const projectThreads = threads.filter((thread) =>
const projectThreads = knownThreads.filter((thread) =>
memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
);
const isWholeGroup = members.length === group.memberProjects.length;
Expand All @@ -681,9 +719,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
const confirmed = await settlePromise(() =>
api.dialogs.confirm(
[
projectThreads.length > 0
? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`
: `Remove project "${targetLabel}"?`,
`Remove "${targetLabel}" and permanently delete every thread attached to ${members.length === 1 ? "this project entry" : "these project entries"}, including archived threads?`,
...(singleMember
? [
`Path: ${singleMember.workspaceRoot}`,
Expand All @@ -693,8 +729,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
]
: [`This removes ${members.length} grouped project entries.`]),
...(projectThreads.length > 0
? ["This permanently clears conversation history for those threads."]
? [
`This includes ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}.`,
]
: []),
"This permanently clears all associated conversation history.",
isWholeGroup
? "This removes only the project entries, not the files on disk."
: "Other entries in this grouped project are unaffected.",
Expand All @@ -707,17 +746,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {

const draftStore = useComposerDraftStore.getState();
for (const member of members) {
const memberThreads = projectThreads.filter(
(thread) =>
thread.environmentId === member.environmentId && thread.projectId === member.id,
);
const memberThreadCount = projectThreadCount(member, projectThreads);
const result = mapAtomCommandResult(
await deleteProject({
environmentId: member.environmentId,
input: {
projectId: member.id,
...(memberThreads.length > 0 ? { force: true } : {}),
},
input: projectDeleteCommandInput(member.id, memberThreadCount),
}),
() => undefined,
);
Expand All @@ -740,12 +773,14 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
}
},
[
archivedThreadsError,
archivedThreadsFailedEnvironmentIds,
deleteProject,
group.displayName,
group.memberProjects.length,
knownThreads,
navigate,
reportFailure,
threads,
],
);

Expand Down Expand Up @@ -1009,6 +1044,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
<Button
size="xs"
variant="destructive-outline"
disabled={archivedThreadsLoading}
onClick={() => void removeMembers([selectedCheckout])}
>
<Trash2Icon className="size-3.5" />
Expand Down Expand Up @@ -1148,16 +1184,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
}
description={
group.memberProjects.length > 1
? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
: "Deletes the project entry and its threads. Files on disk are not touched."
? `Deletes all ${group.memberProjects.length} checkout entries and all their threads, including archived threads, on every machine. Files on disk are not touched.`
: "Deletes the project entry and all its threads, including archived threads. Files on disk are not touched."
}
control={
<Button
variant="destructive-outline"
disabled={archivedThreadsLoading}
onClick={() => void removeMembers(group.memberProjects)}
>
<Trash2Icon />
{group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"}
Remove project
</Button>
}
/>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/archivedThreadsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentI
export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): {
readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
readonly error: string | null;
readonly failedEnvironmentIds: ReadonlyArray<EnvironmentId>;
readonly isLoading: boolean;
readonly refresh: () => void;
} {
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/lib/projectRemoval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { EnvironmentId, ProjectId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
hasArchivedThreadSnapshotFailure,
projectDeleteCommandInput,
projectThreadCount,
} from "./projectRemoval";

describe("hasArchivedThreadSnapshotFailure", () => {
it("only reports failures for environments being removed", () => {
const environmentOne = EnvironmentId.make("environment-1");
const environmentTwo = EnvironmentId.make("environment-2");
const failedEnvironmentIds = [environmentTwo];

expect([
hasArchivedThreadSnapshotFailure([{ environmentId: environmentOne }], failedEnvironmentIds),
hasArchivedThreadSnapshotFailure([{ environmentId: environmentTwo }], failedEnvironmentIds),
]).toEqual([false, true]);
});
});

describe("projectDeleteCommandInput", () => {
it("force-deletes threads even when only archived threads exist", () => {
const project = {
environmentId: EnvironmentId.make("environment-1"),
id: ProjectId.make("project-1"),
};
const archivedThreads = [
{
environmentId: project.environmentId,
projectId: project.id,
},
];

expect(
projectDeleteCommandInput(project.id, projectThreadCount(project, archivedThreads)),
).toEqual({
projectId: "project-1",
force: true,
});
});

it("does not force an empty project when the same project id has threads in another environment", () => {
const projectId = ProjectId.make("shared-project-id");
const environmentOneProject = {
environmentId: EnvironmentId.make("environment-1"),
id: projectId,
};
const environmentTwoProject = {
environmentId: EnvironmentId.make("environment-2"),
id: projectId,
};
const threads = [
{
environmentId: environmentTwoProject.environmentId,
projectId,
},
];

expect(
projectDeleteCommandInput(projectId, projectThreadCount(environmentOneProject, threads)),
).toEqual({ projectId });
expect(
projectDeleteCommandInput(projectId, projectThreadCount(environmentTwoProject, threads)),
).toEqual({ projectId, force: true });
});
});
44 changes: 44 additions & 0 deletions apps/web/src/lib/projectRemoval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";

interface ProjectRemovalTarget {
readonly environmentId: EnvironmentId;
readonly id: ProjectId;
}

interface ProjectRemovalThread {
readonly environmentId: EnvironmentId;
readonly projectId: ProjectId;
}

export function hasArchivedThreadSnapshotFailure(
projects: ReadonlyArray<{ readonly environmentId: EnvironmentId }>,
failedEnvironmentIds: ReadonlyArray<EnvironmentId>,
): boolean {
return projects.some((project) => failedEnvironmentIds.includes(project.environmentId));
}

export function projectThreadCount(
project: ProjectRemovalTarget,
threads: ReadonlyArray<ProjectRemovalThread>,
): number {
let count = 0;
for (const thread of threads) {
if (thread.environmentId === project.environmentId && thread.projectId === project.id) {
count += 1;
}
}
return count;
}

export function projectDeleteCommandInput(
projectId: ProjectId,
threadCount: number,
): {
readonly projectId: ProjectId;
readonly force?: true;
} {
return {
projectId,
...(threadCount > 0 ? { force: true as const } : {}),
};
}
13 changes: 12 additions & 1 deletion docs/user/project-settings.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Customize a project icon
# Project settings

## Customize a project icon

T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon
paths, and icon links in project HTML files.
Expand All @@ -14,3 +16,12 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa
each checkout in the project group and appears on your connected clients.

To use automatic detection again, select **Automatic**.

## Remove a project

1. Open the project settings.
2. Under **Danger**, select **Remove project**.
3. Confirm the removal.

Removing a project deletes its T3 Code entry and all associated conversation history, including
archived threads. Files in the project's workspace remain on disk.
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/archivedThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ it("does not expose an archived snapshot failure message", () => {
expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({
snapshots: [],
error: "Failed to load archived threads.",
failedEnvironmentIds: [environmentId],
isLoading: false,
});

Expand Down
9 changes: 6 additions & 3 deletions packages/client-runtime/src/state/archivedThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ArchivedSnapshotEntry {
export interface ArchivedThreadSnapshotsState {
readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
readonly error: string | null;
readonly failedEnvironmentIds: ReadonlyArray<EnvironmentId>;
readonly isLoading: boolean;
}

Expand Down Expand Up @@ -46,6 +47,7 @@ export function createArchivedThreadSnapshotsAtomFamily<E>(options: {
return Atom.family((environmentKey: string) =>
Atom.make((get): ArchivedThreadSnapshotsState => {
const snapshots: ArchivedSnapshotEntry[] = [];
const failedEnvironmentIds: EnvironmentId[] = [];
let error: string | null = null;
let isLoading = false;

Expand All @@ -58,12 +60,13 @@ export function createArchivedThreadSnapshotsAtomFamily<E>(options: {
snapshots.push({ environmentId, snapshot });
}

if (error === null && result._tag === "Failure") {
error = "Failed to load archived threads.";
if (result._tag === "Failure") {
failedEnvironmentIds.push(environmentId);
error ??= "Failed to load archived threads.";
}
}

return { snapshots, error, isLoading };
return { snapshots, error, failedEnvironmentIds, isLoading };
}).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)),
);
}
Loading