Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";

import ThreadSidebar from "./Sidebar";
import { useLifecycleNotifications } from "../hooks/useLifecycleNotifications";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";

const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
Expand All @@ -10,6 +11,7 @@ const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;

export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
useLifecycleNotifications();

useEffect(() => {
const onMenuAction = window.desktopBridge?.onMenuAction;
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
resolveAppModelSelectionState,
} from "../../modelSelection";
import { ensureNativeApi, readNativeApi } from "../../nativeApi";
import { requestLocalNotificationPermission } from "../../lib/localNotifications";
import { useStore } from "../../store";
import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
import { cn } from "../../lib/utils";
Expand Down Expand Up @@ -463,6 +464,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
? ["Diff line wrapping"]
: []),
...(settings.attentionNotifications !== DEFAULT_UNIFIED_SETTINGS.attentionNotifications
? ["Attention notifications"]
: []),
...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
? ["Assistant output"]
: []),
Expand All @@ -483,6 +487,7 @@ export function useSettingsRestore(onRestored?: () => void) {
isGitWritingModelDirty,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.attentionNotifications,
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
Expand Down Expand Up @@ -865,6 +870,54 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Attention notifications"
description="Show a local notification when a turn finishes or the agent needs input while T3 Code is in the background."
resetAction={
settings.attentionNotifications !== DEFAULT_UNIFIED_SETTINGS.attentionNotifications ? (
<SettingResetButton
label="attention notifications"
onClick={() =>
updateSettings({
attentionNotifications: DEFAULT_UNIFIED_SETTINGS.attentionNotifications,
})
}
/>
) : null
}
control={
<Switch
checked={settings.attentionNotifications}
onCheckedChange={async (checked) => {
const enabled = Boolean(checked);
if (!enabled) {
updateSettings({ attentionNotifications: false });
return;
}

const permission = await requestLocalNotificationPermission();
if (permission === "granted") {
updateSettings({ attentionNotifications: true });
return;
}

toastManager.add({
type: "warning",
title:
permission === "unsupported"
? "Notifications unavailable"
: "Notifications blocked",
description:
permission === "unsupported"
? "This environment does not support local notifications."
: "Allow notifications for T3 Code in your browser or OS settings to enable this feature.",
});
}}
aria-label="Enable local attention notifications"
/>
}
/>

<SettingsRow
title="New threads"
description="Pick the default workspace mode for newly created draft threads."
Expand Down
271 changes: 271 additions & 0 deletions apps/web/src/hooks/useLifecycleNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import {
EventId,
ProjectId,
ThreadId,
TurnId,
type OrchestrationThreadActivity,
} from "@t3tools/contracts";
import { describe, expect, it } from "vitest";
import { collectLifecycleNotifications } from "../lifecycleNotifications";

function makeActivity(overrides: {
id?: string;
createdAt?: string;
kind?: string;
summary?: string;
tone?: OrchestrationThreadActivity["tone"];
payload?: Record<string, unknown>;
turnId?: string;
}): OrchestrationThreadActivity {
return {
id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()),
createdAt: overrides.createdAt ?? "2026-03-30T10:00:00.000Z",
kind: overrides.kind ?? "tool.started",
summary: overrides.summary ?? "Tool call",
tone: overrides.tone ?? "tool",
payload: overrides.payload ?? {},
turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null,
};
}

function makeThread(
overrides: Partial<
Parameters<typeof collectLifecycleNotifications>[0]["nextThreads"][number]
> = {},
) {
return {
id: ThreadId.makeUnsafe("thread-1"),
projectId: ProjectId.makeUnsafe("project-1"),
title: "Review auth flow",
activities: [],
latestTurn: null,
session: null,
...overrides,
};
}

const projects = [{ id: ProjectId.makeUnsafe("project-1"), name: "t3code" }];

describe("collectLifecycleNotifications", () => {
it("emits a completion notification when a turn newly settles", () => {
const notifications = collectLifecycleNotifications({
previousThreads: [
makeThread({
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "running",
requestedAt: "2026-03-30T10:00:00.000Z",
startedAt: "2026-03-30T10:00:01.000Z",
completedAt: null,
assistantMessageId: null,
},
session: {
provider: "codex",
status: "running",
createdAt: "2026-03-30T10:00:00.000Z",
updatedAt: "2026-03-30T10:00:02.000Z",
orchestrationStatus: "running",
activeTurnId: TurnId.makeUnsafe("turn-1"),
},
}),
],
nextThreads: [
makeThread({
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "completed",
requestedAt: "2026-03-30T10:00:00.000Z",
startedAt: "2026-03-30T10:00:01.000Z",
completedAt: "2026-03-30T10:00:05.000Z",
assistantMessageId: null,
},
session: {
provider: "codex",
status: "ready",
createdAt: "2026-03-30T10:00:00.000Z",
updatedAt: "2026-03-30T10:00:05.000Z",
orchestrationStatus: "ready",
activeTurnId: undefined,
},
}),
],
projects,
});

expect(notifications).toEqual([
{
id: "turn-completed:thread-1:turn-1:2026-03-30T10:00:05.000Z",
kind: "turn-completed",
title: "Turn completed",
body: "Agent finished work in t3code · Review auth flow.",
threadId: ThreadId.makeUnsafe("thread-1"),
},
]);
});

it("emits an attention notification for a newly requested user input", () => {
const notifications = collectLifecycleNotifications({
previousThreads: [makeThread()],
nextThreads: [
makeThread({
activities: [
makeActivity({
id: "evt-user-input",
kind: "user-input.requested",
summary: "Need clarification",
tone: "approval",
payload: {
requestId: "req-user-1",
questions: [
{
id: "q-1",
header: "Clarify",
question: "Which branch should I use?",
options: [{ label: "main", description: "Use main" }],
},
],
},
}),
],
}),
],
projects,
});

expect(notifications[0]).toMatchObject({
id: "user-input:thread-1:req-user-1",
kind: "user-input-requested",
title: "Input needed",
body: "Agent is waiting for your input in t3code · Review auth flow.",
});
});

it("emits an attention notification for a newly requested approval", () => {
const notifications = collectLifecycleNotifications({
previousThreads: [makeThread()],
nextThreads: [
makeThread({
activities: [
makeActivity({
id: "evt-approval",
kind: "approval.requested",
summary: "Command approval requested",
tone: "approval",
payload: {
requestId: "req-approval-1",
requestKind: "command",
detail: "bun run lint",
},
}),
],
}),
],
projects,
});

expect(notifications[0]).toMatchObject({
id: "approval:thread-1:req-approval-1",
kind: "approval-requested",
title: "Approval needed",
body: "Agent needs approval in t3code · Review auth flow.",
});
});

it("does not duplicate a completion that was already observed", () => {
const completedThread = makeThread({
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "completed",
requestedAt: "2026-03-30T10:00:00.000Z",
startedAt: "2026-03-30T10:00:01.000Z",
completedAt: "2026-03-30T10:00:05.000Z",
assistantMessageId: null,
},
session: {
provider: "codex",
status: "ready",
createdAt: "2026-03-30T10:00:00.000Z",
updatedAt: "2026-03-30T10:00:05.000Z",
orchestrationStatus: "ready",
activeTurnId: undefined,
},
});

const notifications = collectLifecycleNotifications({
previousThreads: [completedThread],
nextThreads: [completedThread],
projects,
});

expect(notifications).toEqual([]);
});

it("prioritizes pending attention requests over completion notifications", () => {
const notifications = collectLifecycleNotifications({
previousThreads: [
makeThread({
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "running",
requestedAt: "2026-03-30T10:00:00.000Z",
startedAt: "2026-03-30T10:00:01.000Z",
completedAt: null,
assistantMessageId: null,
},
session: {
provider: "codex",
status: "running",
createdAt: "2026-03-30T10:00:00.000Z",
updatedAt: "2026-03-30T10:00:02.000Z",
orchestrationStatus: "running",
activeTurnId: TurnId.makeUnsafe("turn-1"),
},
}),
],
nextThreads: [
makeThread({
activities: [
makeActivity({
id: "evt-approval",
kind: "approval.requested",
summary: "Command approval requested",
tone: "approval",
payload: {
requestId: "req-approval-1",
requestKind: "command",
detail: "bun run lint",
},
}),
],
latestTurn: {
turnId: TurnId.makeUnsafe("turn-1"),
state: "completed",
requestedAt: "2026-03-30T10:00:00.000Z",
startedAt: "2026-03-30T10:00:01.000Z",
completedAt: "2026-03-30T10:00:05.000Z",
assistantMessageId: null,
},
session: {
provider: "codex",
status: "ready",
createdAt: "2026-03-30T10:00:00.000Z",
updatedAt: "2026-03-30T10:00:05.000Z",
orchestrationStatus: "ready",
activeTurnId: undefined,
},
}),
],
projects,
});

expect(notifications).toEqual([
{
id: "approval:thread-1:req-approval-1",
kind: "approval-requested",
title: "Approval needed",
body: "Agent needs approval in t3code · Review auth flow.",
threadId: ThreadId.makeUnsafe("thread-1"),
},
]);
});
});
Loading
Loading