feat: Add auto-close countdown delay to completion overlay (#162)

* 🐛 fix(opencode): add missing @plannotator/server dependency

The opencode build failed because @plannotator/server was not declared
in apps/opencode-plugin/package.json, causing bun bundler to fail
resolving workspace packages.

* ✨ feat(ui): add auto-close delay with countdown on completion overlay

- Extend auto-close setting from boolean to delay options (off/immediate/3s/5s)
- Extract shared CompletionOverlay component from both editor and review apps
- Show countdown timer on completion screen when delay is set
- Add checkbox on completion overlay to enable auto-close (defaults to 3s)
- Replace toggle with select dropdown in Settings

* ♻️ refactor(ui): extract useAutoClose hook and clean up completion overlay

- Extract countdown logic into useAutoClose hook with phase state machine
- Extract SVG icons into named components (CheckIcon, ChatBubbleIcon)
- Add AUTO_CLOSE_OPTIONS constant for dropdown single source of truth
- Remove dead getAutoClose/setAutoClose boolean wrappers

* 🐛 fix(ui): handle window.close() failure with fallback message

Browsers block window.close() on tabs not opened by script. Add tryClose()
helper that detects failure via window.closed check, and show a fallback
message guiding the user to close manually.
This commit is contained in:
김영준E
2026-02-20 02:13:37 +09:00
committed by GitHub
parent b2fc5ab849
commit 3a9a38e9d2
8 changed files with 248 additions and 134 deletions
+2 -1
View File
@@ -35,7 +35,8 @@
"prepublishOnly": "bun run build"
},
"dependencies": {
"@opencode-ai/plugin": "^1.1.10"
"@opencode-ai/plugin": "^1.1.10",
"@plannotator/server": "workspace:*"
},
"peerDependencies": {
"bun": ">=1.0.0"
+4 -2
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "plannotator",
@@ -52,9 +53,10 @@
},
"apps/opencode-plugin": {
"name": "@plannotator/opencode",
"version": "0.7.0",
"version": "0.8.1",
"dependencies": {
"@opencode-ai/plugin": "^1.1.10",
"@plannotator/server": "workspace:*",
},
"peerDependencies": {
"bun": ">=1.0.0",
@@ -122,7 +124,7 @@
},
"packages/server": {
"name": "@plannotator/server",
"version": "0.7.0",
"version": "0.8.1",
"peerDependencies": {
"bun": ">=1.0.0",
},
+14 -53
View File
@@ -16,7 +16,8 @@ import { TableOfContents } from '@plannotator/ui/components/TableOfContents';
import { useSharing } from '@plannotator/ui/hooks/useSharing';
import { useAgents } from '@plannotator/ui/hooks/useAgents';
import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection';
import { storage, getAutoClose } from '@plannotator/ui/utils/storage';
import { storage } from '@plannotator/ui/utils/storage';
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian';
import { getBearSettings } from '@plannotator/ui/utils/bear';
@@ -591,9 +592,6 @@ const App: React.FC = () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (getAutoClose()) {
window.close();
}
setSubmitted('approved');
} catch {
setIsSubmitting(false);
@@ -615,9 +613,6 @@ const App: React.FC = () => {
},
})
});
if (getAutoClose()) {
window.close();
}
setSubmitted('denied');
} catch {
setIsSubmitting(false);
@@ -636,9 +631,6 @@ const App: React.FC = () => {
annotations,
}),
});
if (getAutoClose()) {
window.close();
}
setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay
} catch {
setIsSubmitting(false);
@@ -1230,49 +1222,18 @@ const App: React.FC = () => {
)}
{/* Completion overlay - shown after approve/deny */}
{submitted && (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md px-8">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
submitted === 'approved'
? 'bg-success/20 text-success'
: 'bg-accent/20 text-accent'
}`}>
{submitted === 'approved' ? (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
{submitted === 'approved' ? 'Plan Approved' : annotateMode ? 'Annotations Sent' : 'Feedback Sent'}
</h2>
<p className="text-muted-foreground">
{submitted === 'approved'
? `${agentName} will proceed with the implementation.`
: annotateMode
? `${agentName} will address your annotations on the file.`
: `${agentName} will revise the plan based on your annotations.`}
</p>
</div>
<div className="pt-4 border-t border-border space-y-2">
<p className="text-sm text-muted-foreground">
You can close this tab and return to <span className="text-foreground font-medium">{agentName}</span>.
</p>
<p className="text-xs text-muted-foreground/60">
Your response has been sent.
</p>
</div>
</div>
</div>
)}
<CompletionOverlay
submitted={submitted}
title={submitted === 'approved' ? 'Plan Approved' : annotateMode ? 'Annotations Sent' : 'Feedback Sent'}
subtitle={
submitted === 'approved'
? `${agentName} will proceed with the implementation.`
: annotateMode
? `${agentName} will address your annotations on the file.`
: `${agentName} will revise the plan based on your annotations.`
}
agentLabel={agentName}
/>
{/* Update notification */}
<UpdateBanner origin={origin} />
+12 -48
View File
@@ -4,7 +4,8 @@ import { ModeToggle } from '@plannotator/ui/components/ModeToggle';
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
import { Settings } from '@plannotator/ui/components/Settings';
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
import { storage, getAutoClose } from '@plannotator/ui/utils/storage';
import { storage } from '@plannotator/ui/utils/storage';
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
import { getIdentity } from '@plannotator/ui/utils/identity';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types';
@@ -425,9 +426,6 @@ const ReviewApp: React.FC = () => {
}),
});
if (res.ok) {
if (getAutoClose()) {
window.close();
}
setSubmitted('feedback');
} else {
throw new Error('Failed to send');
@@ -453,9 +451,6 @@ const ReviewApp: React.FC = () => {
}),
});
if (res.ok) {
if (getAutoClose()) {
window.close();
}
setSubmitted('approved');
} else {
throw new Error('Failed to send');
@@ -874,47 +869,16 @@ const ReviewApp: React.FC = () => {
/>
{/* Completion overlay - shown after approve/feedback */}
{submitted && (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md px-8">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
submitted === 'approved'
? 'bg-success/20 text-success'
: 'bg-accent/20 text-accent'
}`}>
{submitted === 'approved' ? (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
{submitted === 'approved' ? 'Changes Approved' : 'Feedback Sent'}
</h2>
<p className="text-muted-foreground">
{submitted === 'approved'
? `${origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} will proceed with the changes.`
: `${origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} will address your review feedback.`}
</p>
</div>
<div className="pt-4 border-t border-border space-y-2">
<p className="text-sm text-muted-foreground">
You can close this tab and return to <span className="text-foreground font-medium">{origin === 'claude-code' ? 'Claude Code' : 'OpenCode'}</span>.
</p>
<p className="text-xs text-muted-foreground/60">
Your response has been sent.
</p>
</div>
</div>
</div>
)}
<CompletionOverlay
submitted={submitted}
title={submitted === 'approved' ? 'Changes Approved' : 'Feedback Sent'}
subtitle={
submitted === 'approved'
? `${origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} will proceed with the changes.`
: `${origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} will address your review feedback.`
}
agentLabel={origin === 'claude-code' ? 'Claude Code' : 'OpenCode'}
/>
{/* Update notification */}
<UpdateBanner origin={origin} />
@@ -0,0 +1,99 @@
import { useAutoClose } from '../hooks/useAutoClose';
// ---------------------------------------------------------------------------
// Icons
// ---------------------------------------------------------------------------
const CheckIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
const ChatBubbleIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
);
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface CompletionOverlayProps {
submitted: 'approved' | 'denied' | 'feedback' | null | false;
title: string;
subtitle: string;
agentLabel: string;
}
export function CompletionOverlay({ submitted, title, subtitle, agentLabel }: CompletionOverlayProps) {
const { state, enableAndStart } = useAutoClose(!!submitted);
if (!submitted) return null;
const isApproved = submitted === 'approved';
return (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md px-8">
<div
className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
isApproved ? 'bg-success/20 text-success' : 'bg-accent/20 text-accent'
}`}
>
{isApproved ? <CheckIcon /> : <ChatBubbleIcon />}
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<p className="text-muted-foreground">{subtitle}</p>
</div>
<div className="pt-4 border-t border-border space-y-2">
{state.phase === 'counting' ? (
<>
<p className="text-sm text-muted-foreground">
This tab will close in <span className="text-foreground font-medium">{state.remaining}</span> second
{state.remaining !== 1 ? 's' : ''}...
</p>
<p className="text-xs text-muted-foreground/60">You can change this in Settings.</p>
</>
) : state.phase === 'closeFailed' ? (
<>
<p className="text-sm text-muted-foreground">
Could not close this tab automatically. Please close it manually.
</p>
<p className="text-xs text-muted-foreground/60">
Auto-close works when the tab is opened by {agentLabel}.
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
You can close this tab and return to <span className="text-foreground font-medium">{agentLabel}</span>.
</p>
{state.phase === 'prompt' ? (
<>
<label className="flex items-center justify-center gap-2 cursor-pointer group">
<input type="checkbox" checked={false} onChange={enableAndStart} className="accent-primary" />
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
Auto-close this tab after 3 seconds
</span>
</label>
<p className="text-xs text-muted-foreground/60">You can change the delay in Settings.</p>
</>
) : (
<p className="text-xs text-muted-foreground/60">Your response has been sent.</p>
)}
</>
)}
</div>
</div>
</div>
);
}
+21 -26
View File
@@ -35,7 +35,7 @@ import {
PERMISSION_MODE_OPTIONS,
type PermissionMode,
} from '../utils/permissionMode';
import { getAutoClose, setAutoClose } from '../utils/storage';
import { getAutoCloseDelay, setAutoCloseDelay, AUTO_CLOSE_OPTIONS, type AutoCloseDelay } from '../utils/storage';
import {
getDefaultNotesApp,
saveDefaultNotesApp,
@@ -72,7 +72,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
const [uiPrefs, setUiPrefs] = useState<UIPreferences>({ tocEnabled: true, stickyActionsEnabled: true });
const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
const [agentWarning, setAgentWarning] = useState<string | null>(null);
const [autoClose, setAutoCloseState] = useState(false);
const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
// Fetch available agents for OpenCode
@@ -96,7 +96,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
setPlanSave(getPlanSaveSettings());
setUiPrefs(getUIPreferences());
setPermissionMode(getPermissionModeSettings().mode);
setAutoCloseState(getAutoClose());
setAutoCloseDelayState(getAutoCloseDelay());
setDefaultNotesApp(getDefaultNotesApp());
// Validate agent setting when dialog opens
@@ -373,31 +373,26 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
<div className="border-t border-border" />
{/* Auto-close Tab */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Auto-close Tab</div>
<div className="text-xs text-muted-foreground">
Close browser tab after submitting
</div>
</div>
<button
role="switch"
aria-checked={autoClose}
onClick={() => {
const next = !autoClose;
setAutoCloseState(next);
setAutoClose(next);
<div className="space-y-2">
<div className="text-sm font-medium">Auto-close Tab</div>
<select
value={autoCloseDelay}
onChange={(e) => {
const next = e.target.value as AutoCloseDelay;
setAutoCloseDelayState(next);
setAutoCloseDelay(next);
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
autoClose ? 'bg-primary' : 'bg-muted'
}`}
className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
autoClose ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
{AUTO_CLOSE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="text-[10px] text-muted-foreground/70">
{AUTO_CLOSE_OPTIONS.find(o => o.value === autoCloseDelay)?.description}
</div>
</div>
</>
)}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from 'react';
import { getAutoCloseDelay, setAutoCloseDelay } from '../utils/storage';
/**
* Phases of the auto-close lifecycle after a form submission.
*
* - idle: nothing submitted yet
* - counting: countdown is ticking (seconds remaining in `remaining`)
* - prompt: auto-close is disabled; offer the user a checkbox to opt in
* - closed: window.close() succeeded (terminal state)
* - closeFailed: window.close() was blocked by the browser
*/
type AutoClosePhase =
| { phase: 'idle' }
| { phase: 'counting'; remaining: number }
| { phase: 'prompt' }
| { phase: 'closed' }
| { phase: 'closeFailed' };
interface UseAutoCloseReturn {
state: AutoClosePhase;
/** User opted in via the checkbox — persist "3s" and start countdown. */
enableAndStart: () => void;
}
function tryClose(onFail: () => void): void {
window.close();
// window.close() is silently ignored when the tab wasn't opened by script.
// Check after a short delay whether we're still alive.
setTimeout(() => {
if (!window.closed) onFail();
}, 300);
}
/**
* Manages the auto-close countdown that runs after a submission overlay appears.
*
* @param active - pass `true` once the overlay should appear (i.e. form was submitted)
*/
export function useAutoClose(active: boolean): UseAutoCloseReturn {
const [state, setState] = useState<AutoClosePhase>({ phase: 'idle' });
// On activation, read persisted delay and transition to the right phase.
useEffect(() => {
if (!active) return;
const delay = getAutoCloseDelay();
if (delay === '0') {
tryClose(() => setState({ phase: 'closeFailed' }));
setState({ phase: 'closed' });
} else if (delay !== 'off') {
setState({ phase: 'counting', remaining: Number(delay) });
} else {
setState({ phase: 'prompt' });
}
}, [active]);
// Tick the countdown once per second.
useEffect(() => {
if (state.phase !== 'counting') return;
if (state.remaining <= 0) {
tryClose(() => setState({ phase: 'closeFailed' }));
return;
}
const timer = setTimeout(
() => setState((prev) => (prev.phase === 'counting' ? { phase: 'counting', remaining: prev.remaining - 1 } : prev)),
1000,
);
return () => clearTimeout(timer);
}, [state]);
const enableAndStart = useCallback(() => {
setAutoCloseDelay('3');
setState({ phase: 'counting', remaining: 3 });
}, []);
return { state, enableAndStart };
}
+18 -4
View File
@@ -53,15 +53,29 @@ function escapeRegex(str: string): string {
/**
* Auto-close tab setting
* Values: 'off' | '0' (immediate) | '3' | '5' (seconds)
* Legacy 'true' maps to '0' for backward compatibility.
*/
const AUTO_CLOSE_KEY = 'plannotator-auto-close';
export function getAutoClose(): boolean {
return getItem(AUTO_CLOSE_KEY) === 'true';
export type AutoCloseDelay = 'off' | '0' | '3' | '5';
export const AUTO_CLOSE_OPTIONS: { value: AutoCloseDelay; label: string; description: string }[] = [
{ value: 'off', label: 'Off', description: 'Tab stays open after submitting' },
{ value: '0', label: 'Immediately', description: 'Tab closes immediately after submitting' },
{ value: '3', label: 'After 3 seconds', description: 'Tab closes 3 seconds after submitting' },
{ value: '5', label: 'After 5 seconds', description: 'Tab closes 5 seconds after submitting' },
];
export function getAutoCloseDelay(): AutoCloseDelay {
const val = getItem(AUTO_CLOSE_KEY);
if (val === '0' || val === '3' || val === '5') return val;
if (val === 'true') return '0'; // backward compat
return 'off';
}
export function setAutoClose(enabled: boolean): void {
setItem(AUTO_CLOSE_KEY, String(enabled));
export function setAutoCloseDelay(delay: AutoCloseDelay): void {
setItem(AUTO_CLOSE_KEY, delay);
}
/**