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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
diffIgnoreWhitespace: true,
diffLayout: "stacked",
environmentIdentificationMode: "artwork",
enableCompletionSounds: false,
favorites: [],
fontFamilyCode: "",
fontFamilyComposer: "",
Expand Down
Binary file added apps/mobile/assets/interaction-sounds/bloom.wav
Binary file not shown.
Binary file added apps/mobile/assets/interaction-sounds/success.wav
Binary file not shown.
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
import { InteractionSoundCoordinator } from "./features/interaction-sounds/InteractionSoundCoordinator";
import {
AppearancePreferencesProvider,
useAppearancePreferences,
Expand Down Expand Up @@ -76,6 +77,7 @@ function AppContent() {
return (
<>
<SplashScreenCoordinator />
<InteractionSoundCoordinator />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useAtomValue } from "@effect/atom-react";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import {
observeThreadSoundState,
shouldPlayInteractionSound,
type InteractionSoundCue,
type ThreadSoundStateByKey,
} from "@t3tools/client-runtime/interaction-sounds";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { useAudioPlayer } from "expo-audio";
import { AsyncResult } from "effect/unstable/reactivity";
import { useEffect, useMemo, useRef } from "react";

import { mobilePreferencesAtom } from "../../state/preferences";
import { liveEnvironmentIdsAtom } from "../../state/shell";
import { useThreadRefs, useThreadShell } from "../../state/entities";
import { replayInteractionSound } from "./interactionSoundPlayback";

const SUCCESS_SOUND = require("../../../assets/interaction-sounds/success.wav");
const BLOOM_SOUND = require("../../../assets/interaction-sounds/bloom.wav");

type InteractionSoundPlayers = Readonly<
Record<InteractionSoundCue, ReturnType<typeof useAudioPlayer>>
>;

export function InteractionSoundCoordinator() {
const threadRefs = useThreadRefs();
const liveEnvironmentIds = useAtomValue(liveEnvironmentIdsAtom);
const preferences = useAtomValue(mobilePreferencesAtom);
const successPlayer = useAudioPlayer(SUCCESS_SOUND);
const bloomPlayer = useAudioPlayer(BLOOM_SOUND);
const previouslyLiveEnvironmentIdsRef = useRef(new Set<ScopedThreadRef["environmentId"]>());
const players = useMemo<InteractionSoundPlayers>(
() => ({ bloom: bloomPlayer, success: successPlayer }),
[bloomPlayer, successPlayer],
);
const settingsHydrated = AsyncResult.isSuccess(preferences);
const completionSoundEnabled = settingsHydrated
? preferences.value.completionSoundEnabled !== false
: true;

useEffect(() => {
for (const environmentId of liveEnvironmentIds) {
previouslyLiveEnvironmentIdsRef.current.add(environmentId);
}
}, [liveEnvironmentIds]);

return threadRefs.map((threadRef) => (
<InteractionSoundThreadCoordinator
key={scopedThreadKey(threadRef)}
threadRef={threadRef}
environmentLive={liveEnvironmentIds.has(threadRef.environmentId)}
environmentPreviouslyLive={
liveEnvironmentIds.has(threadRef.environmentId) ||
previouslyLiveEnvironmentIdsRef.current.has(threadRef.environmentId)
}
completionSoundEnabled={completionSoundEnabled}
settingsHydrated={settingsHydrated}
players={players}
/>
));
}

function InteractionSoundThreadCoordinator({
threadRef,
environmentLive,
environmentPreviouslyLive,
completionSoundEnabled,
settingsHydrated,
players,
}: {
readonly threadRef: ScopedThreadRef;
readonly environmentLive: boolean;
readonly environmentPreviouslyLive: boolean;
readonly completionSoundEnabled: boolean;
readonly settingsHydrated: boolean;
readonly players: InteractionSoundPlayers;
}) {
const thread = useThreadShell(threadRef);
const previousStateRef = useRef<ThreadSoundStateByKey | null>(null);
const environmentObservedLiveRef = useRef(environmentPreviouslyLive);

useEffect(() => {
if (thread === null) {
return;
}
const environmentWasLive = environmentObservedLiveRef.current || environmentPreviouslyLive;
const observation = observeThreadSoundState(previousStateRef.current, thread, {
environmentLive,
environmentPreviouslyLive: environmentWasLive,
settingsHydrated,
});
if (environmentLive || environmentPreviouslyLive) {
environmentObservedLiveRef.current = true;
}
previousStateRef.current = observation.state;
for (const cue of observation.cues) {
if (shouldPlayInteractionSound(cue, completionSoundEnabled)) {
void replayInteractionSound(players[cue]).catch((error: unknown) => {
console.warn(`[interaction-sounds] Could not play ${cue} cue.`, error);
});
}
}
}, [
completionSoundEnabled,
environmentLive,
environmentPreviouslyLive,
players,
settingsHydrated,
thread,
]);

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { replayInteractionSound } from "./interactionSoundPlayback";

describe("mobile interaction sounds", () => {
it("starts a fresh player without seeking", async () => {
const player = {
currentTime: 0,
play: vi.fn(),
seekTo: vi.fn(() => Promise.resolve()),
};

await replayInteractionSound(player);

expect(player.seekTo).not.toHaveBeenCalled();
expect(player.play).toHaveBeenCalledTimes(1);
});

it("rewinds a previously played cue before replaying it", async () => {
const calls: string[] = [];
const player = {
currentTime: 0.4,
play: vi.fn(() => {
calls.push("play");
}),
seekTo: vi.fn(async () => {
calls.push("seek");
}),
};

await replayInteractionSound(player);

expect(player.seekTo).toHaveBeenCalledWith(0);
expect(calls).toEqual(["seek", "play"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { AudioPlayer } from "expo-audio";

type InteractionSoundPlayer = Pick<AudioPlayer, "currentTime" | "play" | "seekTo">;

export async function replayInteractionSound(player: InteractionSoundPlayer): Promise<void> {
if (player.currentTime > 0) {
await player.seekTo(0);
}
player.play();
}
12 changes: 12 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -542,11 +542,23 @@ function ConfiguredSettingsRouteScreen() {
}

function GeneralSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const completionSoundEnabled = AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.completionSoundEnabled !== false
: true;

return (
<SettingsSection title="General">
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<AutoSettleSettingsRows />
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
<SettingsSwitchRow
icon="speaker.wave.2"
label="Completion Sound"
value={completionSoundEnabled}
onValueChange={(value) => savePreferences({ completionSoundEnabled: value })}
/>
</SettingsSection>
);
}
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface Preferences {
readonly lightThemeId?: MobileThemeId;
readonly darkThemeId?: MobileThemeId;
readonly themeMode?: MobileThemeMode;
readonly completionSoundEnabled?: boolean;
readonly baseFontSize?: number;
readonly terminalFontSize?: number | null;
readonly markdownFontSize?: number;
Expand Down Expand Up @@ -90,6 +91,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
lightThemeId?: MobileThemeId;
darkThemeId?: MobileThemeId;
themeMode?: MobileThemeMode;
completionSoundEnabled?: boolean;
baseFontSize?: number;
terminalFontSize?: number | null;
markdownFontSize?: number;
Expand Down Expand Up @@ -133,6 +135,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
) {
preferences.themeMode = parsed.themeMode;
}
if (typeof parsed.completionSoundEnabled === "boolean") {
preferences.completionSoundEnabled = parsed.completionSoundEnabled;
}
if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
preferences.terminalFontSize = parsed.terminalFontSize;
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/state/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
return useAtomValue(environmentThreadShells.threadShellsAtom);
}

export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {
return useAtomValue(environmentThreadShells.threadRefsAtom);
}

export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null {
return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref));
}
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/state/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createEnvironmentShellAtoms,
createEnvironmentShellSummaryAtom,
createEnvironmentSnapshotAtom,
createLiveEnvironmentIdsAtom,
createShellEnvironmentAtoms,
} from "@t3tools/client-runtime/state/shell";

Expand All @@ -15,3 +16,9 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
shellStateValueAtom: environmentShell.stateValueAtom,
});

export const liveEnvironmentIdsAtom = createLiveEnvironmentIdsAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
shellStateValueAtom: environmentShell.stateValueAtom,
label: "mobile-live-environment-ids",
});
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
latestTurn: {
turnId: asTurnId("turn-1"),
state: "completed",
initiatingUserMessageId: null,
requestedAt: "2026-02-24T00:00:08.000Z",
startedAt: "2026-02-24T00:00:08.000Z",
completedAt: "2026-02-24T00:00:08.000Z",
Expand Down Expand Up @@ -471,6 +472,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
latestTurn: {
turnId: asTurnId("turn-1"),
state: "completed",
initiatingUserMessageId: null,
requestedAt: "2026-02-24T00:00:08.000Z",
startedAt: "2026-02-24T00:00:08.000Z",
completedAt: "2026-02-24T00:00:08.000Z",
Expand Down Expand Up @@ -1649,6 +1651,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
assert.equal(threadShell._tag, "Some");
if (threadShell._tag === "Some") {
assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running"));
assert.equal(
threadShell.value.latestTurn?.initiatingUserMessageId,
asMessageId("message-user-2"),
);
assert.equal(threadShell.value.latestTurn?.state, "running");
assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
}
Expand All @@ -1657,6 +1663,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
assert.equal(threadDetail._tag, "Some");
if (threadDetail._tag === "Some") {
assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running"));
assert.equal(
threadDetail.value.latestTurn?.initiatingUserMessageId,
asMessageId("message-user-2"),
);
assert.equal(threadDetail.value.latestTurn?.state, "running");
assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
const ProjectionLatestTurnDbRowSchema = Schema.Struct({
threadId: ProjectionThread.fields.threadId,
turnId: TurnId,
pendingMessageId: Schema.NullOr(MessageId),
state: Schema.String,
requestedAt: IsoDateTime,
startedAt: Schema.NullOr(IsoDateTime),
Expand Down Expand Up @@ -322,6 +323,7 @@ function mapLatestTurn(
): OrchestrationLatestTurn {
return {
turnId: row.turnId,
initiatingUserMessageId: row.pendingMessageId,
state:
row.state === "error"
? "error"
Expand Down Expand Up @@ -772,6 +774,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
turns.thread_id AS "threadId",
turns.turn_id AS "turnId",
turns.pending_message_id AS "pendingMessageId",
turns.state,
turns.requested_at AS "requestedAt",
turns.started_at AS "startedAt",
Expand All @@ -796,6 +799,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
turns.thread_id AS "threadId",
turns.turn_id AS "turnId",
turns.pending_message_id AS "pendingMessageId",
turns.state,
turns.requested_at AS "requestedAt",
turns.started_at AS "startedAt",
Expand All @@ -822,6 +826,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
turns.thread_id AS "threadId",
turns.turn_id AS "turnId",
turns.pending_message_id AS "pendingMessageId",
turns.state,
turns.requested_at AS "requestedAt",
turns.started_at AS "startedAt",
Expand Down Expand Up @@ -1391,6 +1396,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
turns.thread_id AS "threadId",
turns.turn_id AS "turnId",
turns.pending_message_id AS "pendingMessageId",
turns.state,
turns.requested_at AS "requestedAt",
turns.started_at AS "startedAt",
Expand Down Expand Up @@ -2002,6 +2008,7 @@ pending_approval_requests AS (
}
latestTurnByThread.set(row.threadId, {
turnId: row.turnId,
initiatingUserMessageId: row.pendingMessageId,
state:
row.state === "error"
? "error"
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-router": "^1.160.2",
"class-variance-authority": "^0.7.1",
"cuelume": "^0.2.1",
"culori": "^4.0.2",
"effect": "catalog:",
"heic-to": "^1.5.2",
Expand Down
Loading
Loading