Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion .storybook/stories/Skills.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const FilePreviewStates: Story = {
expiresAt={new Date(Date.now() + 15_000).toISOString()}
summary="Deleted 2 skills. 5 symlinks removed."
onUndo={() => undefined}
onUndoComplete={() => undefined}
toastId="storybook-undo-toast"
/>
</StoryCard>
</StoryGrid>
Expand Down
46 changes: 37 additions & 9 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ import tsPrefixer from 'eslint-config-ts-prefixer'
import reactYouMightNotNeedAnEffect from 'eslint-plugin-react-you-might-not-need-an-effect'
import { defineConfig } from 'eslint/config'

/**
* Rules intentionally enabled for @laststance/react-next-eslint-plugin v2.2.0.
* Keeping the list explicit means dependency upgrades cannot silently turn on a
* new rule without a focused lint-fix pass in the same PR.
*/
const laststanceReactNextRuleNames = [
'all-memo',
'jsx-no-useless-fragment',
'no-context-provider',
'no-deopt-use-callback',
'no-deopt-use-memo',
'no-direct-use-effect',
'no-duplicate-key',
'no-forward-ref',
'no-jsx-without-return',
'no-missing-button-type',
'no-missing-component-display-name',
'no-missing-key',
'no-nested-component-definitions',
'no-set-state-prop-drilling',
'no-use-reducer',
'prefer-stable-context-value',
'prefer-usecallback-for-memoized-component',
'prefer-usecallback-might-work',
'prefer-usememo-for-memoized-component',
'prefer-usememo-might-work',
]

const laststanceReactNextRules = Object.fromEntries(
laststanceReactNextRuleNames.map((ruleName) => [
`@laststance/react-next/${ruleName}`,
'error',
]),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default defineConfig([
...tsPrefixer,
{
Expand Down Expand Up @@ -52,19 +87,12 @@ export default defineConfig([
'@laststance/react-next': laststanceReactNextPlugin,
},
rules: {
'@laststance/react-next/no-forward-ref': 'error',
'@laststance/react-next/no-context-provider': 'error',
'@laststance/react-next/no-missing-key': 'error',
'@laststance/react-next/no-duplicate-key': 'error',
'@laststance/react-next/no-jsx-without-return': 'error',
'@laststance/react-next/all-memo': 'error',
'@laststance/react-next/no-use-reducer': 'error',
...laststanceReactNextRules,
// Keep the stricter prop-drilling depth while still enforcing the rule at error severity.
'@laststance/react-next/no-set-state-prop-drilling': [
'error',
{ depth: 1 },
],
'@laststance/react-next/no-deopt-use-callback': 'error',
'@laststance/react-next/prefer-stable-context-value': 'error',
},
},
])
9 changes: 5 additions & 4 deletions src/renderer/settings/sections/About.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ExternalLink } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import React, { useCallback, useState } from 'react'
import { match } from 'ts-pattern'

import { Button } from '@/renderer/src/components/ui/button'
import { Separator } from '@/renderer/src/components/ui/separator'
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
import { SKILLS_DESKTOP_REPOSITORY_URL } from '@/shared/constants'

import { SectionFrame } from './SectionFrame'
Expand Down Expand Up @@ -68,7 +69,7 @@ export const About = React.memo(function About(): React.ReactElement {
const isUpdaterAvailable = Boolean(updateApi)
const [checkStatus, setCheckStatus] = useState<CheckStatus>({ kind: 'idle' })

useEffect(() => {
useComponentEffect(() => {
if (!updateApi) return
const cleanups = [
updateApi.onChecking(() => setCheckStatus({ kind: 'checking' })),
Expand All @@ -85,11 +86,11 @@ export const About = React.memo(function About(): React.ReactElement {
}
}, [updateApi])

const handleCheckForUpdates = (): void => {
const handleCheckForUpdates = useCallback((): void => {
if (!updateApi) return
setCheckStatus({ kind: 'checking' })
void updateApi.check()
}
}, [updateApi])

const status = statusLabel(checkStatus)

Expand Down
49 changes: 29 additions & 20 deletions src/renderer/settings/sections/Agents.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Eye, EyeOff } from 'lucide-react'
import React, { useEffect } from 'react'
import React, { useCallback } from 'react'

import { Button } from '@/renderer/src/components/ui/button'
import { Checkbox } from '@/renderer/src/components/ui/checkbox'
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
import { useUpdateSettings } from '@/renderer/src/hooks/useUpdateSettings'
import { cn, toggleArrayMember } from '@/renderer/src/lib/utils'
import { useAppDispatch, useAppSelector } from '@/renderer/src/redux/hooks'
Expand Down Expand Up @@ -43,7 +44,7 @@ export const Agents = React.memo(function Agents(): React.ReactElement {
const hiddenAgentIds = useAppSelector(selectHiddenAgentIds)
const updateSettings = useUpdateSettings()

useEffect(() => {
useComponentEffect(() => {
// Deliberately keyed on `agents.length` only (NOT `loading`). The
// length value transitions 0 → N exactly once per mount lifecycle,
// so the effect dispatches once and never re-fires within a mount.
Expand All @@ -67,20 +68,23 @@ export const Agents = React.memo(function Agents(): React.ReactElement {
).length
const hiddenCount = installed.length - visibleCount

const handleToggle = (agentId: AgentId): void => {
// Inversion: "Show in sidebar" checkbox flip ⇄ membership in
// hiddenAgentIds. We treat every click as a toggle relative to the
// latest known state instead of trusting the next-state from the
// checkbox event — the schema upstream already pins membership, so
// either side of the flip lands on the correct array.
updateSettings({
hiddenAgentIds: toggleArrayMember(hiddenAgentIds, agentId),
})
}
const handleToggle = useCallback(
(agentId: AgentId): void => {
// Inversion: "Show in sidebar" checkbox flip ⇄ membership in
// hiddenAgentIds. We treat every click as a toggle relative to the
// latest known state instead of trusting the next-state from the
// checkbox event — the schema upstream already pins membership, so
// either side of the flip lands on the correct array.
updateSettings({
hiddenAgentIds: toggleArrayMember(hiddenAgentIds, agentId),
})
},
[hiddenAgentIds, updateSettings],
)

const handleShowAll = (): void => {
const handleShowAll = useCallback((): void => {
updateSettings({ hiddenAgentIds: [] })
}
}, [updateSettings])

return (
<SectionFrame
Expand Down Expand Up @@ -172,17 +176,22 @@ const AgentToggleRow = React.memo(function AgentToggleRow({
onToggle,
}: AgentToggleRowProps): React.ReactElement {
const checkboxId = `agent-visibility-${agent.id}`
const handleCheckedChange = useCallback(
(next: boolean | 'indeterminate'): void => {
// Radix passes `boolean | "indeterminate"`. Ignore the indeterminate
// case — we never set it, but treating it like a flip would dispatch
// a phantom hide/show.
if (typeof next === 'boolean') onToggle(agent.id)
},
[agent.id, onToggle],
)

return (
<li className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-muted/30">
<Checkbox
id={checkboxId}
checked={isVisible}
onCheckedChange={(next) => {
// Radix passes `boolean | "indeterminate"`. Ignore the indeterminate
// case — we never set it, but treating it like a flip would dispatch
// a phantom hide/show.
if (typeof next === 'boolean') onToggle(agent.id)
}}
onCheckedChange={handleCheckedChange}
aria-label={`Show ${agent.name} in sidebar`}
/>
<label
Expand Down
57 changes: 37 additions & 20 deletions src/renderer/settings/sections/General.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Files, Info } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import React, { useCallback, useState } from 'react'

import { Button } from '@/renderer/src/components/ui/button'
import { Input } from '@/renderer/src/components/ui/input'
import {
ToggleGroup,
ToggleGroupItem,
} from '@/renderer/src/components/ui/toggle-group'
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
import { useUpdateSettings } from '@/renderer/src/hooks/useUpdateSettings'
import { useAppSelector } from '@/renderer/src/redux/hooks'
import { TERMINAL_APP_IDS, TERMINAL_APP_UI_LABELS } from '@/shared/constants'
Expand Down Expand Up @@ -70,10 +71,13 @@ export const General = React.memo(function General(): React.ReactElement {
settings.customTerminalAppName ?? '',
)

const handleDefaultTabChange = (nextValue: string): void => {
if (nextValue !== 'files' && nextValue !== 'info') return
updateSettings({ defaultSkillTab: nextValue })
}
const handleDefaultTabChange = useCallback(
(nextValue: string): void => {
if (nextValue !== 'files' && nextValue !== 'info') return
updateSettings({ defaultSkillTab: nextValue })
},
[updateSettings],
)

const handlePreferredTerminalChange = (
e: React.ChangeEvent<HTMLSelectElement>,
Expand All @@ -94,12 +98,19 @@ export const General = React.memo(function General(): React.ReactElement {
* schema so the renderer never asks main to write a value that the
* schema would reject.
*/
const handleCustomNameBlur = (): void => {
const handleCustomNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
setCustomNameDraft(e.target.value)
},
[],
)

const handleCustomNameBlur = useCallback((): void => {
const trimmed = customNameDraft.trim()
if (trimmed === '') return
if (trimmed === settings.customTerminalAppName) return
updateSettings({ customTerminalAppName: trimmed })
}
}, [customNameDraft, settings.customTerminalAppName, updateSettings])

const isCustom = settings.preferredTerminal === 'custom'
const isDraftBlank = customNameDraft.trim() === ''
Expand All @@ -118,7 +129,7 @@ export const General = React.memo(function General(): React.ReactElement {
const [isMainWindowAvailable, setIsMainWindowAvailable] =
useState<boolean>(true)

useEffect(() => {
useComponentEffect(() => {
let cancelled = false
// Attach `.catch` explicitly — `void promise.then(...)` only suppresses
// TypeScript's "promise not handled" hint, it does NOT silence a real
Expand Down Expand Up @@ -147,20 +158,28 @@ export const General = React.memo(function General(): React.ReactElement {
* flag so the button's disabled state + inline message kick in instead
* of failing silently. Disk write only happens on a real bounds value.
*/
const handleSaveCurrentSize = async (): Promise<void> => {
const bounds = await window.electron.window.getMainBounds()
if (bounds === null) {
const handleSaveCurrentSize = useCallback(async (): Promise<void> => {
try {
const bounds = await window.electron.window.getMainBounds()
if (bounds === null) {
setIsMainWindowAvailable(false)
return
}
updateSettings({ windowSize: bounds })
} catch {
setIsMainWindowAvailable(false)
return
}
updateSettings({ windowSize: bounds })
}
}, [updateSettings])

const handleResetWindowSize = (): void => {
const handleSaveCurrentSizeClick = useCallback((): void => {
void handleSaveCurrentSize()
}, [handleSaveCurrentSize])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleResetWindowSize = useCallback((): void => {
// `undefined` removes the persisted size; on next launch the main
// window falls back to the default 1200×800 + maximize() behavior.
updateSettings({ windowSize: undefined })
}
}, [updateSettings])

const persistedWindowSize = settings.windowSize
const hasCustomWindowSize = persistedWindowSize !== undefined
Expand Down Expand Up @@ -224,7 +243,7 @@ export const General = React.memo(function General(): React.ReactElement {
<Input
type="text"
value={customNameDraft}
onChange={(e) => setCustomNameDraft(e.target.value)}
onChange={handleCustomNameChange}
onBlur={handleCustomNameBlur}
placeholder="e.g. Hyper"
maxLength={CUSTOM_APP_NAME_MAX_LENGTH}
Expand Down Expand Up @@ -273,9 +292,7 @@ export const General = React.memo(function General(): React.ReactElement {
type="button"
variant="outline"
size="sm"
onClick={() => {
void handleSaveCurrentSize()
}}
onClick={handleSaveCurrentSizeClick}
disabled={!isMainWindowAvailable}
>
Use current window size
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const toastClassNames = {
'bg-popover text-muted-foreground border-border hover:bg-accent hover:text-foreground',
} as const

const toastOptions = {
classNames: toastClassNames,
} satisfies React.ComponentProps<typeof Toaster>['toastOptions']

/**
* Inline style on the Toaster itself. Sonner reads `--normal-bg` /
* `--normal-border` / `--normal-text` off the toaster root and forwards them
Expand Down Expand Up @@ -136,7 +140,7 @@ const App = React.memo(function App(): React.ReactElement {
theme={mode}
className="toaster group"
style={toasterStyle}
toastOptions={{ classNames: toastClassNames }}
toastOptions={toastOptions}
/>
</TooltipProvider>
)
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/SkipToMainContentLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type { ReactElement } from 'react'
* <main id="main-content">...</main>
*/
export const SkipToMainContentLink = memo(
(): ReactElement => {
function SkipToMainContentLink(): ReactElement {
return (
<a
href="#main-content"
Expand Down
Loading
Loading