Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e48319e
Bring back the tracking interface on top of the server-side branch
mihow Sep 22, 2026
acb0d4e
feat(ui): show the tracking interface only on projects that switch it on
mihow Sep 22, 2026
e9564e5
feat(ui): let project members start a tracking run from the jobs page…
mihow Sep 22, 2026
3b12b9f
fix(ui): keep the plain box label on captures when tracking is off
mihow Sep 22, 2026
307cdcb
fix(ui): offer a tracking run only to people who can change the project
mihow Sep 22, 2026
7a0c9d2
Merge the tracking server work into the tracking interface branch
mihow Sep 22, 2026
ad31b9f
fix(ui): send the delay field the jobs API requires when starting a t…
mihow Sep 22, 2026
455a724
Merge the server-side tracking branch into the interface branch
mihow Sep 22, 2026
eb4c780
feat(ui): offer the tracks CSV export
mihow Sep 22, 2026
a51245b
Merge regroup track splitting from the server-side branch
mihow Sep 22, 2026
b94ba6c
Merge the import-order fix from the server-side branch
mihow Sep 22, 2026
78e1a17
Merge the demo project opt-in from the server-side branch
mihow Sep 23, 2026
9f348e9
Merge the pgvector update from the server-side branch
mihow Sep 23, 2026
badd284
Merge the latest server-side tracking fixes into the interface branch
mihow Sep 28, 2026
b92bb5f
fix(ui): call the track editing mode "Edit track" instead of "Extend …
mihow Sep 23, 2026
6ee5604
feat(ui): sort merge candidates by species and by frame count
mihow Sep 23, 2026
8016f6a
feat(ui): move to the next occurrence after confirming one from the o…
mihow Sep 23, 2026
0eb5f5e
fix(ui): only advance after a confirm if the dialog still shows that …
mihow Sep 23, 2026
49b3e60
fix(ui): clear the selected tab when the dialog closes after the last…
mihow Sep 23, 2026
7dd6593
test(ui): name the last-item adjacency test for what it checks
mihow Sep 23, 2026
72b5c6e
feat(ui): offer Run tracking to everyone the server lets run it
mihow Sep 23, 2026
1167486
feat(ui): rank the occurrences a frame can be moved to by how well th…
mihow Sep 23, 2026
e0345cc
fix(ui): say when the candidate list failed to load instead of asking…
mihow Sep 23, 2026
172bfd8
fix(ui): show track edit actions on a capture only to people who may …
mihow Sep 23, 2026
cf36244
fix(ui): open edit-track mode only for an editable occurrence in this…
mihow Sep 23, 2026
b77e9e8
fix(ui): give the move-frame dialog room for its ranked columns
mihow Sep 23, 2026
866e532
fix(ui): explain why merge candidates failed to load and stop retryin…
mihow Sep 23, 2026
08fc072
fix(ui): name tracking and other post-processing jobs in the jobs list
mihow Sep 23, 2026
81b3309
fix(ui): keep an occurrence open after confirming it when it is not i…
mihow Sep 23, 2026
7c3f8a0
fix(ui): offer the tracks export only on projects that use tracking
mihow Sep 23, 2026
f2d9321
fix(ui): mark Edit in session view with a pencil icon
mihow Sep 23, 2026
2d10fe4
fix(ui): keep the move dialog compact with species names on one line
mihow Sep 23, 2026
5204269
fix(ui): keep the dialog's previous and next in the order the list wa…
mihow Sep 23, 2026
c9d2253
feat(ui): say which sessions tracking skips in the Run tracking form
mihow Sep 23, 2026
440bb33
fix(ui): describe the sessions tracking skips as the server decides them
mihow Sep 23, 2026
3ea3f8d
refactor(ui): skip the list snapshot copy when the dialog has no list…
mihow Sep 23, 2026
be07593
fix(ui): open the More filters section when a date filter is active
mihow Sep 29, 2026
9f1ffba
fix(ui): type track score fields as nullable, as the server sends them
mihow Sep 29, 2026
07342d4
Merge #1272 into the tracking UI branch
mihow Sep 29, 2026
1e0a04f
feat(ui): page through a track's frames on the occurrence page
mihow Sep 29, 2026
da39f63
Merge #1272 into the tracking UI branch
mihow Sep 29, 2026
335765d
Merge the tracking server branch into feat/tracking-ui
mihow Sep 30, 2026
9a642d3
Merge the tracking server branch into feat/tracking-ui
mihow Sep 30, 2026
5227f91
Merge the tracking server branch into feat/tracking-ui
mihow Sep 30, 2026
d098429
Merge the tracking server PR's review fixes into the tracking UI PR
mihow Sep 30, 2026
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
4 changes: 2 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-portal": "^1.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,49 @@
align-items: center;
gap: 40px;
overflow-x: auto;
// The scroll box clips at its edge, so leave room for the last tile's focus ring.
padding-bottom: 4px;
}

// A film strip: one item per row, each divided from the one above it.
.filmStrip {
align-items: stretch;
gap: 0;

.blueprintItem + .blueprintItem {
border-top: 1px solid var(--color-border);
}
}

.blueprintItem {
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
gap: 16px;
max-width: 100%;
padding: 16px 0;
box-sizing: border-box;

.crop {
flex: 0 0 96px;
display: flex;
align-items: center;
justify-content: center;
}

img {
max-width: 100%;
height: inherit;
max-height: 120px;
width: auto;
height: auto;
vertical-align: middle;
}

.blueprintInfo {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
max-width: 100%;
color: var(--color-foreground);
padding: 8px;
// Stands in for a crop that never existed or failed to load: the bounding box, empty.
.missingCrop {
box-sizing: border-box;
@include body-small;
line-height: 12px;

span {
white-space: nowrap;
}
border: 2px dashed var(--color-neutral-300);
border-radius: 2px;
background-color: var(--color-generic-white);
}

.count {
Expand Down
107 changes: 79 additions & 28 deletions ui/src/components/blueprint-collection/blueprint-collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import classNames from 'classnames'
import { LicenseInfo } from 'components/license-info/license-info'
import { ChevronRightIcon } from 'lucide-react'
import { buttonVariants } from 'nova-ui-kit'
import { cn } from 'nova-ui-kit/utils'
import { ReactNode, useState } from 'react'
import { Link } from 'react-router-dom'
import { STRING, translate } from 'utils/language'
import styles from './blueprint-collection.module.scss'
import { missingCropSize } from './crop-size'

export interface BlueprintItem {
id: string
Expand All @@ -18,9 +20,12 @@ export interface BlueprintItem {

export const BlueprintCollection = ({
children,
filmStrip,
showLicenseInfo,
}: {
children: ReactNode
/** Lay the children out as a divided vertical run; pick this for lists of frames. */
filmStrip?: boolean
showLicenseInfo?: boolean
}) => (
<div className={classNames(styles.blueprint)}>
Expand All @@ -29,13 +34,62 @@ export const BlueprintCollection = ({
<LicenseInfo />
</div>
) : null}
<div className={styles.blueprintContent}>{children}</div>
<div
className={classNames(styles.blueprintContent, {
[styles.filmStrip]: filmStrip,
})}
>
{children}
</div>
</div>
)

/**
* The frame's crop, or an empty box with the bounding box's proportions when the crop
* was never made or no longer loads, so the frame still reads as a detection.
*/
const CropImage = ({
image,
}: {
image: { src?: string | null; width: number; height: number }
}) => {
const [failed, setFailed] = useState(false)

if (image.src && !failed) {
return (
<img
src={image.src}
alt=""
width={image.width}
height={image.height}
onError={() => setFailed(true)}
/>
)
}

return (
<div
className={styles.missingCrop}
role="img"
aria-label={translate(STRING.TRACK_FRAME_NO_CROP)}
title={translate(STRING.TRACK_FRAME_NO_CROP)}
style={missingCropSize(image.width, image.height)}
/>
)
}

export const BlueprintItem = ({
actions,
caption,
item,
onLinkClick,
selected,
}: {
actions?: ReactNode
caption?: ReactNode
onLinkClick?: () => void
/** The frame a link pointed at. */
selected?: boolean
item: {
id: string
image: { src: string; width: number; height: number }
Expand All @@ -44,42 +98,39 @@ export const BlueprintItem = ({
countLabel: string
to?: string
}
}) => {
const [size, setSize] = useState({
width: item.image.width,
height: item.image.height,
})

return (
<div className={classNames(styles.blueprintItem, 'group')}>
<div className={styles.blueprintInfo} style={{ width: size.width }}>
<span className="grow text-muted-foreground text-right">
}) => (
<div
aria-current={selected || undefined}
className={classNames(styles.blueprintItem, 'group', {
'bg-primary-100': selected,
})}
>
<div className={styles.crop}>
<CropImage image={item.image} />
</div>
{/* The details take the rest of the row, so their width never follows the crop's. */}
<div className="flex flex-col grow min-w-0 gap-1">
{caption ?? (
<span className="body-small text-muted-foreground">
{item.timeLabel}
</span>
</div>
<div className="flex flex-col items-center gap-2">
<img
src={item.image.src}
alt=""
width={size.width}
height={size.height}
onLoad={(e) => {
setSize({
width: e.currentTarget.width,
height: e.currentTarget.height,
})
}}
/>
)}
<div className="flex items-center gap-1">
{item.to ? (
<Link
className={buttonVariants({ size: 'small', variant: 'ghost' })}
className={cn(
buttonVariants({ size: 'small', variant: 'ghost' }),
'px-2 -ml-2'
)}
onClick={onLinkClick}
to={item.to}
>
<span>{translate(STRING.VIEW_IN_SESSION)}</span>
<ChevronRightIcon className="w-4 h-4" />
</Link>
) : null}
{actions}
</div>
</div>
)
}
</div>
)
16 changes: 16 additions & 0 deletions ui/src/components/blueprint-collection/crop-size.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { missingCropSize } from './crop-size'

describe('missingCropSize', () => {
test('a wide box fills the column width and keeps its proportions', () => {
expect(missingCropSize(300, 100)).toEqual({ width: 96, height: 32 })
})

test('a tall box is capped by the column height instead', () => {
expect(missingCropSize(100, 300)).toEqual({ width: 40, height: 120 })
})

test('unknown proportions give a square filling the column', () => {
expect(missingCropSize(0, 0)).toEqual({ width: 96, height: 96 })
expect(missingCropSize(NaN, -5)).toEqual({ width: 96, height: 96 })
})
})
17 changes: 17 additions & 0 deletions ui/src/components/blueprint-collection/crop-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const CROP_COLUMN_PX = 96
export const CROP_MAX_HEIGHT_PX = 120

/**
* Pixel size of the empty box that stands in for a missing crop: the bounding box's
* proportions fitted into the crop column, or a square filling it when they are unknown.
*/
export const missingCropSize = (
width: number,
height: number
): { width: number; height: number } => {
const w = width > 0 ? width : CROP_COLUMN_PX
const h = height > 0 ? height : CROP_COLUMN_PX
const scale = Math.min(CROP_COLUMN_PX / w, CROP_MAX_HEIGHT_PX / h)

return { width: Math.round(w * scale), height: Math.round(h * scale) }
}
41 changes: 41 additions & 0 deletions ui/src/components/copy-link-button/copy-link-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { SUCCESS_TIMEOUT } from 'data-services/constants'
import { CheckIcon, LinkIcon } from 'lucide-react'
import { BasicTooltip, Button } from 'nova-ui-kit'
import { useEffect, useState } from 'react'
import { STRING, translate } from 'utils/language'

// Callers pass the text to put on the clipboard, usually a shareable page URL.
export const CopyLinkButton = ({ value }: { value: string }) => {
const [copied, setCopied] = useState(false)

useEffect(() => {
if (!copied) {
return
}

const timeout = setTimeout(() => setCopied(false), SUCCESS_TIMEOUT)

return () => clearTimeout(timeout)
}, [copied])

const label = copied ? translate(STRING.COPIED) : translate(STRING.COPY_LINK)

return (
<BasicTooltip asChild content={label}>
<Button
aria-label={label}
onClick={() =>
navigator.clipboard.writeText(value).then(() => setCopied(true))
}
size="icon"
variant="ghost"
>
{copied ? (
<CheckIcon className="w-4 h-4" />
) : (
<LinkIcon className="w-4 h-4" />
)}
</Button>
</BasicTooltip>
)
}
2 changes: 2 additions & 0 deletions ui/src/components/filtering/filter-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { BooleanFilter } from './filters/boolean-filter'
import { CaptureSetFilter } from './filters/capture-set-filter'
import { DateFilter } from './filters/date-filter'
import { DeviceFilter } from './filters/device-filter'
import { GroupingStatusFilter } from './filters/grouping-status-filter'
import { ImageFilter } from './filters/image-filter'
import { PipelineFilter } from './filters/pipeline-filter'
import { SessionFilter } from './filters/session-filter'
Expand Down Expand Up @@ -35,6 +36,7 @@ const ComponentMap: {
deployment__research_site: SiteFilter,
detections__source_image: ImageFilter,
event: SessionFilter,
grouping_verified: GroupingStatusFilter,
processed: ProcessingStatusFilter,
include_unobserved: BooleanFilter,
job_type_key: TypeFilter,
Expand Down
30 changes: 30 additions & 0 deletions ui/src/components/filtering/filters/grouping-status-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Select } from 'nova-ui-kit'
import { STRING, translate } from 'utils/language'
import { booleanToString, stringToBoolean } from '../utils'
import { FilterProps } from './types'

export const GroupingStatusFilter = ({ value: string, onAdd }: FilterProps) => {
const value = stringToBoolean(string)
const options = [
{ value: true, label: translate(STRING.TRACK_GROUPING_FILTER_YES) },
{ value: false, label: translate(STRING.TRACK_GROUPING_FILTER_NO) },
]

return (
<Select.Root value={booleanToString(value)} onValueChange={onAdd}>
<Select.Trigger>
<Select.Value placeholder={translate(STRING.SELECT_PLACEHOLDER)} />
</Select.Trigger>
<Select.Content>
{options.map((option) => (
<Select.Item
key={booleanToString(option.value)}
value={booleanToString(option.value)}
>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
)
}
Loading