Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bad66ab
feat: triage an issue from the command palette
t3dotgg Oct 21, 2026
3e01c50
fix(triage): clone source in the background and peel annotated tags
t3dotgg Oct 21, 2026
cfa960d
fix(triage): clone the exact commit the session names
t3dotgg Oct 21, 2026
7741ab0
feat(triage): give triage drafts their own hero instead of toasts
t3dotgg Oct 21, 2026
c6503a6
fix(triage): fit the triage hero to the new design
t3dotgg Oct 27, 2026
0f7869e
fix(triage): address council review
t3dotgg Oct 27, 2026
55a6fe8
fix(web): triage keeps the user where they went while it starts
t3dotgg Oct 27, 2026
eabb02e
fix(triage): skip the opening question when the user already describe…
t3dotgg Oct 27, 2026
0ea1034
fix(triage): the triage hero shows from the first frame
t3dotgg Oct 27, 2026
5f3bb47
fix(server): reuse a cached triage clone only when it is complete
t3dotgg Oct 27, 2026
876326d
fix(web): triage seeds reused drafts and never overwrites user text
t3dotgg Oct 27, 2026
f3f886a
fix(web): remote-only homes offer triage on their one capable machine
t3dotgg Oct 27, 2026
8915cbe
fix(server): check a cached triage clone's own .git before reading HEAD
t3dotgg Oct 27, 2026
2d279f2
docs(triage): say which machine triage picks with nothing open
t3dotgg Oct 27, 2026
f395552
fix(mobile): with nothing open, triage picks a connected machine that…
t3dotgg Oct 27, 2026
7d5ea99
test(triage): cover the open-draft seed and the parent-repo clone check
t3dotgg Oct 27, 2026
045db13
fix(mobile): triage route guard compiles under React Compiler
t3dotgg Oct 27, 2026
5abcee5
fix(triage): skip askpass helpers, and mobile reads the thread's mach…
t3dotgg Oct 27, 2026
37270cf
docs(triage): say how mobile picks a machine with nothing open
t3dotgg Oct 27, 2026
7bbd0ec
fix(web): with nothing open, triage skips a primary machine that lack…
t3dotgg Oct 27, 2026
8a00eb5
test(web): cover how the palette picks a triage machine
t3dotgg Oct 27, 2026
1debec4
docs(triage): say the agent asks before it applies a fix or posts
t3dotgg Oct 27, 2026
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
4 changes: 2 additions & 2 deletions .github/triage/PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h

## 1. Ask what went wrong

Your first message to the user: ask them to describe what went wrong, in their own
words. Ask them to paste screenshots directly into this session if they have any.
Unless the user already described it, your first message asks what went wrong, in
their own words. Ask them to paste screenshots directly into this session if they have any.
Ask follow-up questions when the description is vague. Good repro steps are the most
valuable thing you can extract from this conversation.

Expand Down
43 changes: 42 additions & 1 deletion apps/mobile/src/features/keyboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import { uuidv4 } from "../../lib/uuid";
import { useNavigation } from "@react-navigation/native";
import {
Expand Down Expand Up @@ -41,15 +42,25 @@ import { deriveBottomControlInsets } from "../../lib/reserved-regions";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
import { useProjects, useThreadShell, useThreadShells } from "../../state/entities";
import {
useProjects,
useServerConfigs,
useThreadShell,
useThreadShells,
} from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { requestThreadMessageJump } from "../threads/threadMessageJump";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { serverEnvironment } from "../../state/server";
import { useWorkspaceEnvironments } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
import { pickTriageEnvironment } from "../triage/triageTarget";
import { useStartTriage } from "../triage/useStartTriage";
import { useNewTaskFlow } from "../threads/new-task-flow-provider";
import { useActiveNewTask } from "../../state/active-new-task";
import {
filterCommandPaletteItems,
nextPaletteIndex,
Expand Down Expand Up @@ -256,6 +267,22 @@ export function CommandPalette(props: {
query.startsWith(">") ? "" : query,
environmentLabels === undefined ? {} : { environmentLabels },
);
const serverConfigs = useServerConfigs();
const startTriage = useStartTriage();
const newTaskFlow = useNewTaskFlow();
const draftEnvironmentId =
useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null);
const triageEnvironmentId = pickTriageEnvironment({
// The route names the machine even before the thread shell loads.
onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId,
connected: searchEnvironmentIds,
supportsTriage: (environmentId) =>
serverConfigs.get(environmentId)?.environment.capabilities.triage === true,
});
const canTriage =
useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) &&
triageEnvironmentId !== null &&
serverConfigs.get(triageEnvironmentId)?.environment.capabilities.triage === true;
const contentMatchByKey = useMemo(
() =>
new Map(
Expand Down Expand Up @@ -324,6 +351,17 @@ export function CommandPalette(props: {
run: () => openSettings(navigation, "SettingsArchive"),
},
];
if (canTriage) {
actions.push({
key: "triage",
kind: "action",
title: "Triage an issue",
// Mobile has no triage hero, so name the machine before it starts.
detail: `On ${savedConnectionsById[triageEnvironmentId]?.environmentLabel ?? "this machine"}`,
searchTerms: ["triage", "bug", "issue", "report", "broken", "debug", "help", "support"],
run: () => void startTriage(triageEnvironmentId),
});
}
const projectByKey = new Map(
projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]),
);
Expand Down Expand Up @@ -430,12 +468,15 @@ export function CommandPalette(props: {
activeThread,
activeThreadRef,
environments.length,
canTriage,
navigation,
projects,
runCommand,
savedConnectionsById,
selectDetail,
startTriage,
threads,
triageEnvironmentId,
]);
const rankedMessageKeys = useMemo(() => {
const subjectByKey = new Map(
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/triage/triageTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vite-plus/test";

import { pickTriageEnvironment } from "./triageTarget";

describe("pickTriageEnvironment", () => {
it("triages the machine on screen", () => {
expect(
pickTriageEnvironment({
onScreen: "laptop",
connected: ["desktop", "laptop"],
supportsTriage: () => true,
}),
).toBe("laptop");
});

it("with nothing on screen, skips connected machines whose server lacks triage", () => {
expect(
pickTriageEnvironment({
onScreen: null,
connected: ["old-server", "new-server"],
supportsTriage: (environmentId) => environmentId === "new-server",
}),
).toBe("new-server");
});
});
11 changes: 11 additions & 0 deletions apps/mobile/src/features/triage/triageTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* The machine triage runs on: the one on screen, else the first connected
* machine whose server supports triage. The palette row names the pick.
*/
export function pickTriageEnvironment<Id extends string>(input: {
readonly onScreen: Id | null;
readonly connected: ReadonlyArray<Id>;
readonly supportsTriage: (environmentId: Id) => boolean;
}): Id | null {
return input.onScreen ?? input.connected.find(input.supportsTriage) ?? null;
}
106 changes: 106 additions & 0 deletions apps/mobile/src/features/triage/useStartTriage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
import { useNavigation } from "@react-navigation/native";
import * as Clipboard from "expo-clipboard";
import Constants from "expo-constants";

import * as Dialog from "../../components/ui/Dialog";
import { authClientMetadata } from "../../lib/authClientMetadata";
import { projectEnvironment } from "../../state/projects";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import {
createNewTaskDraft,
setComposerDraftText,
waitForComposerDraftsLoaded,
} from "../../state/use-composer-drafts";
import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail";

const CLI_COMMAND = "npx t3 triage";

/**
* In-app `t3 triage`, as on web: the server writes the session files and
* clones the matching source, then a No project draft opens with the prompt.
*/
export function useStartTriage() {
const navigation = useNavigation();
const selectDetail = useSelectWorkspaceDetail();
const prepare = useAtomCommand(serverEnvironment.prepareTriage, { reportFailure: false });
const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });

const fail = (message: string) =>
Dialog.alert(
"Could not start triage",
`${message} Run ${CLI_COMMAND} in a terminal on that machine instead.`,
[
{
text: "Copy command",
onPress: () =>
void Clipboard.setStringAsync(CLI_COMMAND).catch(() =>
Dialog.alert("Could not copy", `Type ${CLI_COMMAND} instead.`),
),
},
{ text: "OK", style: "cancel" },
],
);
const errorMessage = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : fallback;

const activeRoute = () => {
const state = navigation.getState();
return state?.routes[state.index];
};

return async (environmentId: EnvironmentId) => {
// If the user goes elsewhere while this waits, the draft stays closed
// instead of pulling them away. Split view changes params, not the route.
const startRoute = activeRoute();
const routeChanged = () => {
const route = activeRoute();
if (route === startRoute) return false;
if (route === undefined || startRoute === undefined) return true;
return route.key !== startRoute.key || route.params !== startRoute.params;
};
const scratch = await openScratch({ environmentId, input: {} });
if (scratch._tag === "Failure") {
if (!isAtomCommandInterrupted(scratch)) {
fail(
errorMessage(
squashAtomCommandFailure(scratch),
"This machine cannot start threads without a project.",
),
);
}
return;
}
const prepared = await prepare({
environmentId,
input: { client: authClientMetadata(Constants.expoConfig?.version) },
});
if (prepared._tag === "Failure") {
if (!isAtomCommandInterrupted(prepared)) {
fail(errorMessage(squashAtomCommandFailure(prepared), "The server could not prepare it."));
}
return;
}
try {
await waitForComposerDraftsLoaded();
} catch {
fail("Drafts could not be loaded.");
return;
}
if (routeChanged()) return;
const draftId = createNewTaskDraft({
environmentId,
projectId: scratch.value.id,
});
setComposerDraftText(draftId, prepared.value.prompt);
selectDetail({
name: "Thread",
params: { draft: { environmentId, projectId: scratch.value.id, title: "Triage", draftId } },
});
};
}
6 changes: 6 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ describe("RPC authorization scopes", () => {
}
});

it("keeps triage, which seeds an agent that reads logs and the database, behind operate scope", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareTriage)).toBe(
AuthOrchestrationOperateScope,
);
});

it("separates passive terminal observation from operations that can change a shell", () => {
for (const method of [
WS_METHODS.terminalObserve,
Expand Down
81 changes: 13 additions & 68 deletions apps/server/src/cli/triage.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeChildProcess from "node:child_process";
import * as NodeOS from "node:os";
import * as NodeReadlinePromises from "node:readline/promises";

import * as HostProcess from "@t3tools/shared/HostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import * as Config from "effect/Config";
import * as Console from "effect/Console";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { Command, Flag } from "effect/cli";

import packageJson from "../../package.json" with { type: "json" };
import * as BootService from "../cloud/bootService.ts";
import * as ServerConfig from "../config.ts";
import { resolveBaseDir } from "../os-jank.ts";
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { buildTriageLaunchPrompt } from "../triage/triagePrompt.ts";
import { describeServerProcess, writeTriageSession } from "../triage/triageSession.ts";
import { baseDirFlag } from "./config.ts";
import { interactiveStdin } from "./consoleStdin.ts";
import { resolveCliCommand } from "./invocation.ts";
import {
buildTriageContext,
buildTriageLaunchPrompt,
buildTriageSeedPrompt,
} from "./triagePrompt.ts";

interface TriageAgent {
readonly id: "claude" | "codex";
Expand Down Expand Up @@ -67,23 +57,6 @@ export class TriageAgentSpawnError extends Schema.TaggedError<TriageAgentSpawnEr
}
}

export const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* (
serverRuntimeStatePath: string,
) {
const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath);
if (Option.isNone(state)) {
return "not running (no server-runtime.json; the server may never have started here)";
}
const version =
state.value.serverVersion === undefined
? "version not recorded"
: `version ${state.value.serverVersion}`;
if (!isProcessAlive(state.value.pid)) {
return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin}, ${version})`;
}
return `running (pid ${String(state.value.pid)}, ${state.value.origin}, ${version})`;
});

const pickAgent = (agents: ReadonlyArray<TriageAgent>) =>
Effect.promise(async () => {
const readline = NodeReadlinePromises.createInterface({
Expand Down Expand Up @@ -149,49 +122,22 @@ export const triageCommand = Command.make("triage", {
),
Command.withHandler((flags) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

// Triage is a user-facing feature: always the userdata state, never dev.
const explicitBaseDir = Option.getOrUndefined(flags.baseDir);
const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option);
const baseDir = yield* resolveBaseDir(explicitBaseDir ?? Option.getOrUndefined(envHome));
const paths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, {});

const now = yield* DateTime.now;
const scratchDir = path.join(
paths.stateDir,
"triage",
DateTime.formatIso(now).replaceAll(":", "-").replace(".", "-"),
);
yield* fs.makeDirectory(scratchDir, { recursive: true });

const contextFilePath = path.join(scratchDir, "context.md");
yield* fs.writeFileString(
contextFilePath,
buildTriageContext({
generatedAt: DateTime.formatIso(now),
const session = yield* writeTriageSession({
baseDir,
paths,
entry: {
kind: "cli",
cliVersion: packageJson.version,
os: `${yield* HostProcess.Platform} ${yield* HostProcess.Architecture} (${NodeOS.release()})`,
nodeVersion: process.version,
launchedAs: yield* resolveCliCommand("triage"),
server: yield* describeServerProcess(paths.serverRuntimeStatePath),
paths: {
stateDir: paths.stateDir,
dbPath: paths.dbPath,
settingsPath: paths.settingsPath,
logsDir: paths.logsDir,
serviceLogPath: path.join(paths.logsDir, BootService.BOOT_SERVICE_LOG_FILE),
desktopBackendLogGlob: path.join(paths.logsDir, "server-child*.log*"),
serverTracePath: paths.serverTracePath,
providerEventLogPath: paths.providerEventLogPath,
terminalLogsDir: paths.terminalLogsDir,
providerStatusCacheDir: paths.providerStatusCacheDir,
secretsDir: paths.secretsDir,
sourceCacheDir: path.join(baseDir, "source"),
},
}),
);
},
server: yield* describeServerProcess(paths.serverRuntimeStatePath),
});

const installed: Array<TriageAgent> = [];
for (const agent of TRIAGE_AGENTS) {
Expand All @@ -217,9 +163,8 @@ export const triageCommand = Command.make("triage", {
selected = yield* pickAgent(installed);
}

// Windows .cmd shims cannot carry multiline argv; keep the playbook on disk for both launch and paste fallback.
const promptFilePath = path.join(scratchDir, "prompt.md");
yield* fs.writeFileString(promptFilePath, buildTriageSeedPrompt(contextFilePath));
// Windows .cmd shims cannot carry multiline argv; the prompt file also serves as the paste fallback.
const { sessionDir, promptFilePath, contextFilePath } = session;

if (selected === undefined) {
yield* Console.log(
Expand All @@ -242,7 +187,7 @@ export const triageCommand = Command.make("triage", {
buildTriageLaunchPrompt(promptFilePath),
]);
yield* Console.log(`Starting ${selected.label}. It will ask what went wrong.\n`);
const exitCode = yield* runInteractiveSession({ ...spawnSpec, cwd: scratchDir });
const exitCode = yield* runInteractiveSession({ ...spawnSpec, cwd: sessionDir });
if (exitCode !== 0) {
process.exitCode = exitCode;
}
Expand Down
Loading
Loading