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
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