Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)

Command palette (cmd+K) with fuzzy nav, ticket search, people lookup
and action entries (new ticket, logout, show shortcuts). Opens from
keyboard or user dropdown.

Global keyboard shortcuts via a small useShortcut/useLeaderShortcut
hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav.
Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail:
`e` edit, `r` focus comment composer. All guarded against firing
inside text fields.

@mention autocomplete in the comment composer (MentionTextarea) with
arrow-key nav and Tab/Enter insert. Rendered comments and audit log
rewrite @username tokens to links pointing at that user's assignee
filter; unknown usernames left as plain text.

Mobile sweep: TicketDetail sidebar stacks below content on <md,
Settings profile grid collapses to one column, admin tables get
horizontal scroll with a 640px min width, CTI 3-column grid stacks
vertically on <md, New ticket severity/assignee grid same.

PWA: manifest.webmanifest, SVG icon, minimal network-first service
worker for the app shell (never caches /api/*), registered in
production builds only. Theme-color meta + manifest link in index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:36:28 -04:00
parent 4bade22410
commit ef22e92ac8
21 changed files with 976 additions and 28 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
{errors.overview && <p className={errorClass}>{errors.overview.message}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelClass}>Severity</label>
<select
+1 -1
View File
@@ -59,7 +59,7 @@ export default function Settings() {
{/* Profile */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Profile</h2>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={user?.displayName ?? ''} />
<Field label="Username" value={user?.username ?? ''} mono />
<Field label="Email" value={user?.email ?? ''} />
+43 -18
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -23,6 +24,8 @@ import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import CTISelect from '../components/CTISelect';
import Avatar from '../components/Avatar';
import MentionTextarea from '../components/MentionTextarea';
import { injectMentionLinks } from '../lib/mentions';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
@@ -99,6 +102,7 @@ export default function TicketDetail() {
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const [deleteOpen, setDeleteOpen] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [editForm, setEditForm] = useState({ title: '', overview: '' });
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' });
const [statusOpen, setStatusOpen] = useState(false);
@@ -195,6 +199,20 @@ export default function TicketDetail() {
await deleteCommentMutation.mutateAsync(commentId);
};
useShortcut('e', (e) => {
if (!ticket || editing) return;
e.preventDefault();
startEdit();
}, [ticket, editing]);
useShortcut('r', (e) => {
if (!ticket) return;
e.preventDefault();
setTab('comments');
setPreview(false);
setTimeout(() => commentTextareaRef.current?.focus(), 40);
}, [ticket]);
const toggleLog = (logId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev);
@@ -245,7 +263,7 @@ export default function TicketDetail() {
All tickets
</button>
<div className="flex gap-6 items-start">
<div className="flex flex-col-reverse md:flex-row gap-6 md:items-start">
{/* ── Main content ── */}
<div className="flex-1 min-w-0">
{/* Title card */}
@@ -386,7 +404,9 @@ export default function TicketDetail() {
)}
</div>
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.body}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(comment.body, users)}
</ReactMarkdown>
</div>
</div>
</div>
@@ -418,25 +438,30 @@ export default function TicketDetail() {
{preview ? (
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(commentBody, users)}
</ReactMarkdown>
) : (
<span className="text-gray-600 italic">Nothing to preview</span>
)}
</div>
) : (
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
placeholder="Leave a comment… Markdown supported"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitComment(e as unknown as React.FormEvent);
<div className="mb-3">
<MentionTextarea
ref={commentTextareaRef}
value={commentBody}
onChange={setCommentBody}
users={users}
onSubmit={() =>
submitComment({
preventDefault: () => {},
} as unknown as React.FormEvent)
}
}}
/>
placeholder="Leave a comment… Markdown & @mentions"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none"
/>
</div>
)}
<div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
@@ -513,7 +538,7 @@ export default function TicketDetail() {
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.detail!}
{injectMentionLinks(log.detail!, users)}
</ReactMarkdown>
</div>
) : (
@@ -533,7 +558,7 @@ export default function TicketDetail() {
</div>
{/* ── Sidebar ── */}
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
<div className="w-full md:w-64 flex-shrink-0 md:sticky md:top-0 space-y-3">
{/* Ticket Summary */}
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
+39 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
@@ -69,6 +70,7 @@ function sevColor(severity: number) {
export default function Tickets() {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
@@ -83,6 +85,7 @@ export default function Tickets() {
const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [cursor, setCursor] = useState<number>(-1);
const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState<
@@ -213,6 +216,35 @@ export default function Tickets() {
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Keyboard navigation
useEffect(() => {
setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c));
}, [tickets.length]);
useShortcut('j', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1));
}, [tickets.length]);
useShortcut('k', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1));
}, [tickets.length]);
useShortcut('enter', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
navigate(`/${tickets[cursor].displayId}`);
}, [cursor, tickets]);
useShortcut('x', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
toggleOne(tickets[cursor].id);
}, [cursor, tickets]);
return (
<Layout wide>
{/* Status tabs */}
@@ -453,10 +485,14 @@ export default function Tickets() {
</div>
<ul className="divide-y divide-border">
{tickets.map((ticket) => (
{tickets.map((ticket, idx) => (
<li
key={ticket.id}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
idx === cursor
? 'bg-accent/50 ring-1 ring-inset ring-primary'
: 'hover:bg-accent/30'
}`}
>
<input
type="checkbox"
+1 -1
View File
@@ -137,7 +137,7 @@ export default function AdminCTI() {
return (
<Layout title="CTI Configuration">
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:h-[calc(100vh-10rem)]">
{/* Categories */}
<div className={panelClass}>
<div className={panelHeaderClass}>
+2 -2
View File
@@ -193,8 +193,8 @@ export default function AdminUsers() {
</button>
}
>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-gray-800">
<tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
+2 -2
View File
@@ -116,8 +116,8 @@ export default function AdminWebhooks() {
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<div className="rounded-md border border-border overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
<tr>
<th className="text-left px-4 py-2">Name</th>