Skip to content
Draft
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
17 changes: 17 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
getSlashModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
resolveSidebarThreadOrder,
SIDEBAR_THREAD_ORDER_OPTIONS,
} from "./appSettings";

describe("normalizeCustomModelSlugs", () => {
Expand Down Expand Up @@ -82,3 +84,18 @@ describe("getSlashModelOptions", () => {
expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
});
});

describe("resolveSidebarThreadOrder", () => {
it("defaults invalid values to recent-activity", () => {
expect(resolveSidebarThreadOrder("something-else")).toBe("recent-activity");
});

it("keeps the supported thread ordering preferences", () => {
expect(resolveSidebarThreadOrder("recent-activity")).toBe("recent-activity");
expect(resolveSidebarThreadOrder("created-at")).toBe("created-at");
expect(SIDEBAR_THREAD_ORDER_OPTIONS.map((option) => option.value)).toEqual([
"recent-activity",
"created-at",
]);
});
});
22 changes: 22 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/s
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const SIDEBAR_THREAD_ORDER_OPTIONS = [
{
value: "recent-activity",
label: "Recent activity",
description: "Sort chats by the latest user message, final assistant message, plan, or assistant question.",
},
{
value: "created-at",
label: "Created time",
description: "Sort chats by when the thread was originally created.",
},
] as const;
export type SidebarThreadOrder = (typeof SIDEBAR_THREAD_ORDER_OPTIONS)[number]["value"];
const SidebarThreadOrderSchema = Schema.Literals(["recent-activity", "created-at"]);
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};
Expand All @@ -21,6 +35,9 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
sidebarThreadOrder: SidebarThreadOrderSchema.pipe(
Schema.withConstructorDefault(() => Option.some("recent-activity")),
),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
Expand All @@ -32,6 +49,10 @@ export interface AppModelOption {
isCustom: boolean;
}

export function resolveSidebarThreadOrder(value: string | null | undefined): SidebarThreadOrder {
return value === "created-at" ? "created-at" : "recent-activity";
}

const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});

let listeners: Array<() => void> = [];
Expand Down Expand Up @@ -70,6 +91,7 @@ export function normalizeCustomModelSlugs(
function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
sidebarThreadOrder: resolveSidebarThreadOrder(settings.sidebarThreadOrder),
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
};
}
Expand Down
101 changes: 101 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ProjectId,
type ServerConfig,
type ThreadId,
type TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
Expand Down Expand Up @@ -256,6 +257,69 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
};
}

function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-pending-input" as MessageId,
targetText: "pending input thread",
});
const turnId = "turn-pending-user-input" as TurnId;
const pendingUserInputActivity: OrchestrationReadModel["threads"][number]["activities"][number] = {
id: "activity-pending-user-input" as OrchestrationReadModel["threads"][number]["activities"][number]["id"],
createdAt: isoAt(102),
tone: "info",
kind: "user-input.requested",
summary: "User input requested",
turnId,
payload: {
requestId: "req-user-input-browser",
questions: [
{
id: "affected_course",
header: "Affected Course",
question: "Which student calendar is broken?",
options: [
{
label: "The Combine (Recommended)",
description: "Matches the report.",
},
{
label: "The Invitational",
description: "Alternative calendar.",
},
],
},
],
},
};
return {
...snapshot,
threads: snapshot.threads.map((thread) =>
thread.id !== THREAD_ID
? thread
: Object.assign({}, thread, {
latestTurn: {
turnId,
state: "running",
requestedAt: isoAt(100),
startedAt: isoAt(101),
completedAt: null,
assistantMessageId: null,
},
session: {
threadId: THREAD_ID,
status: "running",
providerName: "codex",
runtimeMode: "full-access",
activeTurnId: turnId,
lastError: null,
updatedAt: isoAt(101),
},
activities: [pendingUserInputActivity],
}),
),
};
}

function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-plan-target" as MessageId,
Expand Down Expand Up @@ -858,6 +922,43 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("submits structured user-input answers from a running thread", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotWithPendingUserInput(),
});

try {
const optionButton = page.getByRole("button", { name: /The Combine \(Recommended\)/i });
await expect.element(optionButton).toBeVisible();
await optionButton.click();

const submitButton = page.getByRole("button", { name: /Submit answers/i });
await expect.element(submitButton).toBeEnabled();
await submitButton.click();

await vi.waitFor(
() => {
const dispatches = wsRequests.filter(
(request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand,
);
const userInputCommand = dispatches
.map((request) => request.command as { type?: string; requestId?: string } | undefined)
.find((command) => command?.type === "thread.user-input.respond");
expect(userInputCommand).toEqual(
expect.objectContaining({
type: "thread.user-input.respond",
requestId: "req-user-input-browser",
}),
);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -873,8 +873,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
[threadActivities],
);
const pendingUserInputs = useMemo(
() => derivePendingUserInputs(threadActivities),
[threadActivities],
() =>
derivePendingUserInputs(threadActivities, {
latestTurn: activeLatestTurn,
session: activeThread?.session ?? null,
}),
[activeLatestTurn, activeThread?.session, threadActivities],
);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const activePendingDraftAnswers = useMemo(
Expand Down
44 changes: 42 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,23 @@ describe("resolveThreadStatusPill", () => {
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Planning", pulse: true });
});

it("shows working for non-plan threads that are actively running", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
interactionMode: "default",
},
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Working", pulse: true });
});

it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => {
it("shows plan submitted when a settled plan turn has a proposed plan ready for follow-up", () => {
expect(
resolveThreadStatusPill({
thread: {
Expand All @@ -99,7 +112,34 @@ describe("resolveThreadStatusPill", () => {
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Plan Ready", pulse: false });
).toMatchObject({ label: "Plan Submitted", pulse: false });
});

it("shows errored when the latest turn failed after the thread was last visited", () => {
expect(
resolveThreadStatusPill({
thread: {
...baseThread,
latestTurn: {
turnId: "turn-1" as never,
state: "error",
assistantMessageId: null,
requestedAt: "2026-03-09T10:00:00.000Z",
startedAt: "2026-03-09T10:00:00.000Z",
completedAt: "2026-03-09T10:05:00.000Z",
},
lastVisitedAt: "2026-03-09T10:04:00.000Z",
session: {
...baseThread.session,
status: "error",
updatedAt: "2026-03-09T10:05:00.000Z",
orchestrationStatus: "error",
},
},
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
).toMatchObject({ label: "Errored", pulse: false });
});

it("shows completed when there is an unseen completion and no active blocker", () => {
Expand Down
65 changes: 61 additions & 4 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
export interface ThreadStatusPill {
label:
| "Working"
| "Planning"
| "Connecting"
| "Completed"
| "Pending Approval"
| "Awaiting Input"
| "Plan Ready";
| "Plan Submitted"
| "Errored";
colorClass: string;
dotClass: string;
pulse: boolean;
Expand All @@ -19,6 +21,28 @@ type ThreadStatusInput = Pick<
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
>;

function hasUnreadAt(timestamp: string | undefined, lastVisitedAt: string | undefined): boolean {
if (!timestamp) {
return false;
}

const updatedAt = Date.parse(timestamp);
if (Number.isNaN(updatedAt)) {
return false;
}

if (!lastVisitedAt) {
return true;
}

const visitedAt = Date.parse(lastVisitedAt);
if (Number.isNaN(visitedAt)) {
return true;
}

return updatedAt > visitedAt;
}

export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
Expand Down Expand Up @@ -55,6 +79,15 @@ export function resolveThreadStatusPill(input: {
};
}

if (thread.session?.status === "running" && thread.interactionMode === "plan") {
return {
label: "Planning",
colorClass: "text-cyan-600 dark:text-cyan-300/90",
dotClass: "bg-cyan-500 dark:bg-cyan-300/90",
pulse: true,
};
}

if (thread.session?.status === "running") {
return {
label: "Working",
Expand All @@ -80,9 +113,33 @@ export function resolveThreadStatusPill(input: {
findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null;
if (hasPlanReadyPrompt) {
return {
label: "Plan Ready",
colorClass: "text-violet-600 dark:text-violet-300/90",
dotClass: "bg-violet-500 dark:bg-violet-300/90",
label: "Plan Submitted",
colorClass: "text-teal-600 dark:text-teal-300/90",
dotClass: "bg-teal-500 dark:bg-teal-300/90",
pulse: false,
};
}

if (
thread.latestTurn?.state === "error" &&
hasUnreadAt(thread.latestTurn.completedAt ?? undefined, thread.lastVisitedAt)
) {
return {
label: "Errored",
colorClass: "text-rose-600 dark:text-rose-300/90",
dotClass: "bg-rose-500 dark:bg-rose-300/90",
pulse: false,
};
}

if (
thread.session?.status === "error" &&
hasUnreadAt(thread.session.updatedAt, thread.lastVisitedAt)
) {
return {
label: "Errored",
colorClass: "text-rose-600 dark:text-rose-300/90",
dotClass: "bg-rose-500 dark:bg-rose-300/90",
pulse: false,
};
}
Expand Down
Loading