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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.16.0] — 2026-05-13

### Features

- Tag your projects to group related ones together, then filter the
projects sidebar with one click on a tag chip. Right-click a project
→ **Tags…** to add, remove, or create tags.
- Pick a color for each tag from an 8-swatch palette (or let DPlex
assign one automatically). Tag colors are shared across the sidebar
and command palette, so the same tag always looks the same.
- Global search (⌘P) now matches projects by tag — type `#infra` to
filter, or just type a tag name. Each project result shows its
avatar and tag pills so you can see why it matched.
- New **Search** button in the status bar opens the command palette,
with its ⌘P shortcut shown right on the button.

### Improvements

- Filtering projects by tag keeps a parent's worktree branches visible
underneath it, so a tag on the origin pulls the whole tree along.
- Project rows fit as many tag pills as actually have room, then
surface the rest as a `+N` chip whose tooltip lists what's hidden —
nothing is silently clipped.

## [0.15.0] — 2026-05-12

### Features
Expand Down Expand Up @@ -445,7 +469,8 @@ AI-assisted development.
- Eight built-in themes (dark and light variants).
- Keyboard shortcuts for tabs, splits, sidebar, and settings.

[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.15.0...HEAD
[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.16.0...HEAD
[0.16.0]: https://github.com/Ron537/DPlex/compare/v0.15.0...v0.16.0
[0.15.0]: https://github.com/Ron537/DPlex/compare/v0.14.2...v0.15.0
[0.14.2]: https://github.com/Ron537/DPlex/compare/v0.14.1...v0.14.2
[0.14.1]: https://github.com/Ron537/DPlex/compare/v0.14.0...v0.14.1
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dplex",
"version": "0.15.0",
"version": "0.16.0",
"description": "A terminal multiplexer built for AI-assisted development. Manage multiple AI CLI sessions (Copilot, Claude, and more) alongside regular terminals in one window.",
"main": "./out/main/index.js",
"author": {
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/src/components/layout/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useProvidersStore } from '../../stores/providersStore'
import { useProjectStore } from '../../stores/projectStore'
import { SessionList } from '../sessions/SessionList'
import { ProjectList } from '../projects/ProjectList'
import { TagFilterBar } from '../projects/TagFilterBar'
import { ProjectPanelFooter } from '../projects/ProjectPanelFooter'
import { SessionPanelFooter } from '../sessions/SessionPanelFooter'
import { GitSidePanelView } from '../git/GitSidePanelView'
Expand Down Expand Up @@ -54,6 +55,7 @@ export function SidePanel(): React.JSX.Element | null {
const [showFilterMenu, setShowFilterMenu] = useState(false)
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [projectActiveOnly, setProjectActiveOnly] = useState(false)
const [projectTagFilter, setProjectTagFilter] = useState<string | null>(null)
const [showProjectFilterMenu, setShowProjectFilterMenu] = useState(false)
// Collapse-all signal for SessionList groups. The nonce bumps each time
// the user clicks the toolbar button so each <CollapsibleGroup> can react
Expand Down Expand Up @@ -460,7 +462,14 @@ export function SidePanel(): React.JSX.Element | null {

<div className="flex-1 overflow-y-auto dplex-scroll-autohide">
{activeTab === 'projects' ? (
<ProjectList searchQuery={projectSearchQuery} activeOnly={projectActiveOnly} />
<>
<TagFilterBar value={projectTagFilter} onChange={setProjectTagFilter} />
<ProjectList
searchQuery={projectSearchQuery}
activeOnly={projectActiveOnly}
tagFilter={projectTagFilter}
/>
</>
) : (
<SessionList
groupMode={groupMode}
Expand Down
27 changes: 26 additions & 1 deletion src/renderer/src/components/layout/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Terminal, PanelLeftOpen, PanelLeftClose, Settings } from 'lucide-react'
import { Terminal, PanelLeftOpen, PanelLeftClose, Settings, Search } from 'lucide-react'
import { useTerminalStore } from '../../stores/terminalStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useCommandPaletteStore } from '../../stores/commandPaletteStore'
import { MOD } from '../../utils/shortcuts'

interface StatusBarProps {
Expand Down Expand Up @@ -41,6 +42,30 @@ export function StatusBar({ onOpenSettings }: StatusBarProps): React.JSX.Element
>
{panelCollapsed ? <PanelLeftOpen size={13} /> : <PanelLeftClose size={13} />}
</button>
<button
onClick={() => useCommandPaletteStore.getState().toggle('all')}
className="inline-flex items-center gap-1.5 px-2 rounded-full hover:bg-[var(--dplex-hover)] transition-colors flex-shrink-0"
style={{ height: 18, color: 'var(--dplex-text-muted)' }}
title="Search projects, sessions, settings (try #tag)"
aria-label={`Open command palette (${MOD}P)`}
>
<Search size={11} />
<span>Search</span>
<kbd
className="font-medium"
style={{
fontSize: 10,
padding: '0 4px',
borderRadius: 3,
border: '1px solid var(--dplex-border)',
color: 'var(--dplex-text-dim)',
backgroundColor: 'var(--dplex-bg-input)',
fontFamily: 'inherit'
}}
>
{MOD}P
</kbd>
</button>
</div>
<div className="flex items-center gap-3 pr-1 min-w-0">
{activeSessionCount > 0 && (
Expand Down
150 changes: 150 additions & 0 deletions src/renderer/src/components/projects/InlineTagList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { TagPill } from './TagPill'

interface InlineTagListProps {
tags: readonly string[]
}

/** Gap between tag pills, in px. Mirrors the Tailwind `gap-1` we apply to
* the row so width math matches what flex actually lays out. */
const GAP_PX = 4

/**
* Renders project tag pills inline, fitting as many as actually fit in the
* available row width. Anything that doesn't fit is rolled into a neutral
* `+N` chip with a tooltip listing the hidden tags.
*
* Strategy:
* 1. Render an invisible measurement layer containing every tag pill plus
* a `+N` sample chip. The layer is absolutely-positioned inside the
* container so it doesn't influence layout but its children still get
* real widths from the browser.
* 2. After layout, compute the largest prefix that fits (including the
* `+N` chip's width whenever there will be overflow).
* 3. Render the visible row from that prefix; rerun on container resize.
*
* Two renders per resize is fine — the second one is cheap (same DOM, just
* fewer visible nodes) and only happens when the container actually changes
* size. The measurement layer is keyed on the tag list so adding/removing a
* tag re-runs measurement automatically.
*/
export function InlineTagList({ tags }: InlineTagListProps): React.JSX.Element | null {
const containerRef = useRef<HTMLDivElement | null>(null)
const measureRef = useRef<HTMLDivElement | null>(null)
const [visibleCount, setVisibleCount] = useState(tags.length)

useLayoutEffect(() => {
const container = containerRef.current
const measure = measureRef.current
if (!container || !measure) return

const recompute = (): void => {
const available = container.clientWidth
const measureChildren = Array.from(
measure.querySelectorAll<HTMLElement>('[data-tag-measure]')
)
const plusEl = measure.querySelector<HTMLElement>('[data-tag-plus]')
if (measureChildren.length !== tags.length) return
const widths = measureChildren.map((el) => el.offsetWidth)
const plusWidth = plusEl?.offsetWidth ?? 0

// Fast path: does the entire list fit?
const totalAll =
widths.reduce((s, w) => s + w, 0) + Math.max(0, widths.length - 1) * GAP_PX
if (totalAll <= available) {
setVisibleCount((prev) => (prev === tags.length ? prev : tags.length))
return
}

// Otherwise find the largest k such that the first k pills plus a
// `+N` chip fit. The `+N` chip itself needs space, so we always
// account for its width + a gap before it.
let acc = 0
let k = 0
for (let i = 0; i < widths.length; i++) {
const candidate = acc + (k > 0 ? GAP_PX : 0) + widths[i] + GAP_PX + plusWidth
if (candidate > available) break
acc += (k > 0 ? GAP_PX : 0) + widths[i]
k++
}
setVisibleCount((prev) => (prev === k ? prev : k))
}

recompute()
const ro = new ResizeObserver(recompute)
ro.observe(container)
return () => ro.disconnect()
}, [tags])

if (tags.length === 0) return null

const hiddenCount = Math.max(0, tags.length - visibleCount)
const visibleTags = tags.slice(0, visibleCount)
const hiddenTags = tags.slice(visibleCount)

return (
<div
ref={containerRef}
className="flex items-center gap-1 min-w-0 relative w-full overflow-hidden"
>
{/* Invisible measurement layer — same pills + a sample `+N` chip used
only for width readings. Absolute so it doesn't affect layout. */}
<div
ref={measureRef}
aria-hidden
className="flex items-center gap-1"
style={{
position: 'absolute',
left: 0,
top: 0,
visibility: 'hidden',
pointerEvents: 'none',
whiteSpace: 'nowrap'
}}
>
{tags.map((t) => (
<span key={t} data-tag-measure>
<TagPill tag={t} compact />
</span>
))}
<span data-tag-plus>
<PlusBadge count={Math.max(1, tags.length)} />
</span>
</div>

{/* Visible row */}
{visibleTags.map((t) => (
<TagPill key={t} tag={t} compact />
))}
{hiddenCount > 0 && (
<PlusBadge count={hiddenCount} tooltipTags={hiddenTags} />
)}
</div>
)
}

function PlusBadge({
count,
tooltipTags
}: {
count: number
tooltipTags?: readonly string[]
}): React.JSX.Element {
return (
<span
title={tooltipTags ? tooltipTags.map((t) => `#${t}`).join(', ') : undefined}
className="inline-flex items-center rounded-full font-medium leading-none whitespace-nowrap"
style={{
fontSize: 9.5,
padding: '1px 5px',
backgroundColor: 'var(--dplex-bg-input)',
color: 'var(--dplex-text-muted)',
border: '1px solid var(--dplex-border)',
userSelect: 'none',
flexShrink: 0
}}
>
+{count}
</span>
)
}
42 changes: 42 additions & 0 deletions src/renderer/src/components/projects/ProjectAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { memo } from 'react'
import { getAvatarColor, getAvatarInitials } from '../../utils/projectStatus'

interface ProjectAvatarProps {
/** Stable id used to derive the deterministic avatar color. */
projectId: string
/** Project name used to derive the 1-2 letter glyph. */
name: string
/** Square size in px. Defaults to 22 — the size used in the command palette. */
size?: number
}

/**
* Small square project avatar — deterministic color + initials glyph derived
* from the project's id/name. Used in surfaces that aren't `ProjectItem`
* (e.g. the command palette) so they share the same visual identity as the
* sidebar without duplicating the styling logic.
*/
export const ProjectAvatar = memo(function ProjectAvatar({
projectId,
name,
size = 22
}: ProjectAvatarProps): React.JSX.Element {
const color = getAvatarColor(projectId)
const initials = getAvatarInitials(name)
return (
<span
aria-hidden
className="inline-flex items-center justify-center rounded-md font-bold leading-none"
style={{
width: size,
height: size,
backgroundColor: color.bg,
color: color.fg,
fontSize: Math.max(9, Math.round(size * 0.42)),
flexShrink: 0
}}
>
{initials}
</span>
)
})
Loading