mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user