Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
44 changes: 19 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,11 @@ import {
connectionStatusTitle,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import {
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import {
parseScopedThreadKey,
scopedThreadKey,
Expand DownExpand Up@@ -1232,9 +1236,6 @@ function ChatViewContent(props: ChatViewProps) {
);
const activeServerThread = serverThread ?? loadingServerThread;
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[routeThreadKey],
);
const settings = useEnvironmentSettings(environmentId);
// New-thread defaults live in the primary environment's settings.json (the
// settings UI never writes to remote environments), so read them from the
Expand DownExpand Up@@ -1851,25 +1852,6 @@ function ChatViewContent(props: ChatViewProps) {
[openOrReuseProjectDraftThread],
);

useEffect(() => {
if (!serverThread?.id) return;
const threadUpdatedAt = Date.parse(serverThread.updatedAt);
if (Number.isNaN(threadUpdatedAt)) return;
const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;

markThreadVisited(
scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
serverThread.updatedAt,
);
}, [
activeThreadLastVisitedAt,
markThreadVisited,
serverThread?.environmentId,
serverThread?.id,
serverThread?.updatedAt,
]);

const selectedProviderByThreadId = composerActiveProvider ?? null;
const threadProvider =
activeThread?.modelSelection.instanceId ??
Expand DownExpand Up@@ -3997,13 +3979,18 @@ function ChatViewContent(props: ChatViewProps) {
const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
const nowMinute = useNowMinute();
const snoozeNow = new Date().toISOString();
const activeThreadSnoozed =
activeThreadShell !== null &&
supportsSnooze &&
effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
effectiveSnoozed(activeThreadShell, { now: snoozeNow });
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
void snoozeWakeTick;
const activeThreadWokeAt =
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: snoozeNow })
: null;
useEffect(() => {
void snoozeWakeTick;
if (!activeThreadSnoozed) return;
const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
if (!Number.isFinite(wakeAtMs)) return;
Expand All@@ -4013,6 +4000,10 @@ function ChatViewContent(props: ChatViewProps) {
);
return () => window.clearTimeout(id);
}, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
Comment thread
StiensWout marked this conversation as resolved.
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
Expand DownExpand Up@@ -5054,6 +5045,7 @@ function ChatViewContent(props: ChatViewProps) {
failure = startResult;
} else {
turnStartSucceeded = true;
acknowledgeActiveThreadWoke();
}
}

Expand DownExpand Up@@ -5413,6 +5405,7 @@ function ChatViewContent(props: ChatViewProps) {
}

if (failure === null) {
acknowledgeActiveThreadWoke();
// Optimistically open the plan sidebar when implementing (not refining).
// "default" mode here means the agent is executing the plan, which produces
// step-tracking activities that the sidebar will display.
Expand DownExpand Up@@ -5442,6 +5435,7 @@ function ChatViewContent(props: ChatViewProps) {
[
activeThread,
activeProposedPlan,
acknowledgeActiveThreadWoke,
beginLocalDispatch,
isConnecting,
isSendBusy,
Expand Down
131 changes: 92 additions & 39 deletions apps/web/src/components/SidebarV2.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onUnsettle: (threadRef: ScopedThreadRef) => void;
onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
}) {
const {
Expand All@@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
onCancelRename,
onCommitRename,
onContextMenu,
onAcknowledgeWoke,
onRenameTitleChange,
onSettle,
onSnooze,
Expand DownExpand Up@@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
className: "text-emerald-700 dark:text-emerald-300",
}
: null;
const isWokeStatus = topStatus?.icon === "woke";

const gitCwd = thread.worktreePath ?? props.projectCwd;
const gitStatus = useEnvironmentQuery(
Expand DownExpand Up@@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
},
[onThreadClick, threadRef],
);
const handleAcknowledgeWokeClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (props.wokeAt === null) return;
onAcknowledgeWoke(threadRef, props.wokeAt);
},
[onAcknowledgeWoke, props.wokeAt, threadRef],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
Expand DownExpand Up@@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the time/jump label yields to the settle affordance. */}
{prBadge}
<span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
<span className="inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
<span
className={cn(
"inline-flex justify-end tabular-nums text-muted-foreground/55 transition-opacity",
!isWoke && "group-hover/v2-row:opacity-0",
)}
>
{variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
// Snoozed rows show when they come BACK, not when they were
// last touched — the return ticket is the row's whole story.
Expand All@@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
) : isWoke ? (
// A wake can land straight in the settled tail (e.g. PR
// merged while snoozed); the signal must survive the trip.
<span
role="status"
aria-label="Woke from snooze"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
>
<AlarmClockIcon aria-hidden className="size-3" />
Woke
</span>
<span role="status">Woke</span>
</button>
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
<span className="text-xs">
{variantAction === "unsettle"
Expand All@@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Wake thread now"
onClick={handleUnsnoozeClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<AlarmClockOffIcon className="size-3" />
</button>
Expand All@@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<Undo2Icon className="mb-px size-3.5" />
</button>
Expand All@@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
className={cn(
"pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100",
isWoke && "group-hover/v2-row:static",
)}
>
<CheckIcon className="size-3" />
</button>
Expand DownExpand Up@@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
the hidden state out of flow lets the project label reclaim
space without either state overlapping it. */}
<span className="group/v2-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
{/* pointer-events-none: while hovered this label is absolute
+ opacity-0, which paints it ABOVE the in-flow settle/snooze
buttons; without it the invisible label eats their clicks. */}
{/* Read-only status labels yield to the hover actions. Woke is
itself an action, so it stays pointer-enabled and visible
while the other controls appear beside it. */}
<span
className={cn(
"pointer-events-none self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
snoozeMenuOpen && "absolute right-0 opacity-0",
isWokeStatus
? "pointer-events-auto"
: "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0",
"self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity",
snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
)}
>
{topStatus ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "woke" ? (
isWokeStatus ? (
<button
type="button"
aria-label="Dismiss Woke notification"
title="Dismiss Woke notification"
onClick={handleAcknowledgeWokeClick}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
topStatus.className,
)}
>
<AlarmClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
<span role="status">{topStatus.label}</span>
</button>
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
topStatus.className,
)}
>
{topStatus.icon === "working" ? (
<CircleDashedIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
</span>
) : null}
</span>
)
Comment thread
cursor[bot] marked this conversation as resolved.
) : (
threadTimeLabel(thread)
)}
Expand All@@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
// would keep the controls pinned over the status label
// once the pointer moves away (e.g. after a failed
// settle) instead of cross-fading back.
"absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "static opacity-100",
"pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100",
snoozeMenuOpen && "pointer-events-auto static opacity-100",
)}
>
{showSnoozeButton ? (
Expand DownExpand Up@@ -1288,6 +1333,13 @@ export default function SidebarV2() {
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand DownExpand Up@@ -3022,6 +3074,7 @@ export default function SidebarV2() {
onUnsettle={attemptUnsettle}
onSnooze={attemptSnooze}
onUnsnooze={attemptUnsnooze}
onAcknowledgeWoke={acknowledgeWoke}
onChangeRequestState={handleChangeRequestState}
/>
);
Expand Down
Loading
Loading