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
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"tone": "^15.1.22",
"zod": "^4.3.5"
},
"devDependencies": {
Expand Down
122 changes: 122 additions & 0 deletions packages/ui/src/hooks/useColumnChangeSound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect, useRef } from "react";
import type { Session } from "../data/schema";
import {
getColumn,
playColumnTransitionSound,
type Column,
} from "../sounds/columnSounds";

// Time-based idle detection (matches RepoSection.tsx)
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour

// How long a session must be stable before playing a sound (ms)
// This filters out rapid transitions from tools that are auto-approved
// but not in the daemon's hardcoded list (Edit, Write, Bash with trust, etc.)
const STABILITY_DELAY_MS = 1500;

interface SessionColumnState {
column: Column;
}

interface PendingTransition {
originalColumn: Column;
timer: ReturnType<typeof setTimeout>;
}

/**
* Get the effective column for a session, accounting for time-based idle status.
* Sessions inactive for 1 hour are considered idle regardless of stored status.
*/
function getEffectiveColumn(session: Session): Column {
const elapsed = Date.now() - new Date(session.lastActivityAt).getTime();
if (elapsed > IDLE_TIMEOUT_MS) return "idle";
return getColumn(session.status, session.hasPendingToolUse);
}

/**
* Hook that plays sounds when sessions change columns.
*
* Sounds are debounced with "original state" tracking:
* - When a column change occurs, we record the original column and start a timer
* - Subsequent changes reset the timer but keep the original column
* - When the timer fires, we only play a sound if the current column differs from the original
*
* This prevents sound spam from rapid transitions like:
* Working → Needs Approval (auto-approved tool) → Working
* Since we end up back at "Working" (the original), no sound plays.
*
* The daemon filters some auto-approved tools (Read, Glob, etc.) but not all
* (Edit, Write, Bash with trust settings). This hook provides a safety net.
*
* Call this at the app level or in a component that has access to all sessions.
*/
export function useColumnChangeSound(sessions: Session[]): void {
// Track current column state for each session
const currentStateRef = useRef<Map<string, SessionColumnState>>(new Map());
// Track if this is the initial render (don't play sounds on first load)
const isInitialRef = useRef(true);
// Track pending transitions (original column + timer) for each session
const pendingTransitionsRef = useRef<Map<string, PendingTransition>>(
new Map(),
);

useEffect(() => {
const currentState = currentStateRef.current;
const newState = new Map<string, SessionColumnState>();
const pendingTransitions = pendingTransitionsRef.current;

// Build new state and detect changes
for (const session of sessions) {
const column = getEffectiveColumn(session);
newState.set(session.sessionId, { column });

// Check if column changed (skip on initial render)
if (!isInitialRef.current) {
const prev = currentState.get(session.sessionId);
if (prev && prev.column !== column) {
// Column changed
const existingTransition = pendingTransitions.get(session.sessionId);

// Determine the original column:
// - If there's an existing pending transition, keep its original
// - Otherwise, use the previous column as the new original
const originalColumn = existingTransition
? existingTransition.originalColumn
: prev.column;

// Cancel existing timer if any
if (existingTransition) {
clearTimeout(existingTransition.timer);
}

// Schedule sound to play after delay
const timer = setTimeout(() => {
// Only play sound if current column differs from the original
const finalState = currentStateRef.current.get(session.sessionId);
if (finalState && finalState.column !== originalColumn) {
playColumnTransitionSound(originalColumn, finalState.column);
}
pendingTransitions.delete(session.sessionId);
}, STABILITY_DELAY_MS);

pendingTransitions.set(session.sessionId, { originalColumn, timer });
}
}
}

// Update refs
currentStateRef.current = newState;
isInitialRef.current = false;
}, [sessions]);

// Cleanup timers on unmount
useEffect(() => {
const pendingTransitions = pendingTransitionsRef.current;
return () => {
for (const transition of pendingTransitions.values()) {
clearTimeout(transition.timer);
}
pendingTransitions.clear();
};
}, []);
}
134 changes: 133 additions & 1 deletion packages/ui/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,141 @@
import { createFileRoute } from "@tanstack/react-router";
import { Flex, Text } from "@radix-ui/themes";
import {
Flex,
Text,
Box,
Button,
Code,
IconButton,
Tooltip,
} from "@radix-ui/themes";
import { useEffect, useState } from "react";
import { RepoSection } from "../components/RepoSection";
import { useSessions, groupSessionsByRepo } from "../hooks/useSessions";
import { useColumnChangeSound } from "../hooks/useColumnChangeSound";
import { playColumnSound } from "../sounds/columnSounds";

export const Route = createFileRoute("/")({
component: IndexPage,
});

function Soundboard() {
const [isVisible, setIsVisible] = useState(true);

return (
<Box mb="4">
<Flex align="center" gap="2" mb={isVisible ? "2" : "0"}>
<Text size="2" weight="medium" color="gray">
Soundboard
</Text>
<Tooltip
content="To customize sounds, ask Claude: Change the [column] sound to [description]"
maxWidth="300px"
>
<Box
asChild
style={{
color: "var(--gray-a11)",
cursor: "help",
display: "flex",
}}
>
<span>
<svg
width="14"
height="14"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM8.24992 4.49999C8.24992 4.9142 7.91413 5.24999 7.49992 5.24999C7.08571 5.24999 6.74992 4.9142 6.74992 4.49999C6.74992 4.08577 7.08571 3.74999 7.49992 3.74999C7.91413 3.74999 8.24992 4.08577 8.24992 4.49999ZM6.00003 5.99999H6.50003H7.50003C7.77618 5.99999 8.00003 6.22384 8.00003 6.49999V9.99999H8.50003H9.00003V11H8.50003H7.50003H6.50003H6.00003V9.99999H6.50003H7.00003V6.99999H6.50003H6.00003V5.99999Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</span>
</Box>
</Tooltip>
<IconButton
size="1"
variant="ghost"
color="gray"
onClick={() => setIsVisible(!isVisible)}
>
{isVisible ? (
<svg
width="14"
height="14"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.13523 6.15803C3.3241 5.95657 3.64052 5.94637 3.84197 6.13523L7.5 9.56464L11.158 6.13523C11.3595 5.94637 11.6759 5.95657 11.8648 6.15803C12.0536 6.35949 12.0434 6.67591 11.842 6.86477L7.84197 10.6148C7.64964 10.7951 7.35036 10.7951 7.15803 10.6148L3.15803 6.86477C2.95657 6.67591 2.94637 6.35949 3.13523 6.15803Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.1584 3.13508C6.35985 2.94621 6.67627 2.95642 6.86514 3.15788L10.6151 7.15788C10.7954 7.3502 10.7954 7.64949 10.6151 7.84182L6.86514 11.8418C6.67627 12.0433 6.35985 12.0535 6.1584 11.8646C5.95694 11.6757 5.94673 11.3593 6.1356 11.1579L9.565 7.49985L6.1356 3.84182C5.94673 3.64036 5.95694 3.32394 6.1584 3.13508Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
)}
</IconButton>
</Flex>
{isVisible && (
<Flex gap="2">
<Button
size="1"
color="green"
variant="soft"
onClick={() => playColumnSound("working")}
>
Working
</Button>
<Button
size="1"
color="orange"
variant="soft"
onClick={() => playColumnSound("needs-approval")}
>
Needs Approval
</Button>
<Button
size="1"
color="yellow"
variant="soft"
onClick={() => playColumnSound("waiting")}
>
Waiting
</Button>
<Button
size="1"
color="gray"
variant="soft"
onClick={() => playColumnSound("idle")}
>
Idle
</Button>
</Flex>
)}
</Box>
);
}

function IndexPage() {
const { sessions } = useSessions();

Expand All @@ -18,6 +146,9 @@ function IndexPage() {
return () => clearInterval(interval);
}, []);

// Play sounds when sessions change columns
useColumnChangeSound(sessions);

if (sessions.length === 0) {
return (
<Flex direction="column" align="center" gap="3" py="9">
Expand All @@ -35,6 +166,7 @@ function IndexPage() {

return (
<Flex direction="column">
<Soundboard />
{repoGroups.map((group) => (
<RepoSection
key={group.repoId}
Expand Down
Loading