Skip to content

Commit b685b65

Browse files
jason-rlclaude
andauthored
feat: extract shared object detail fields and fix ResourcePicker height (#204)
## Summary - Extract shared object detail fields into reusable component - Fix ResourcePicker height clipping issue ## Test plan - [x] `pnpm build` passes - [x] Tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 198ad1e commit b685b65

4 files changed

Lines changed: 183 additions & 64 deletions

File tree

src/components/ResourcePicker.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export interface ResourcePickerConfig<T> {
8282

8383
/** Label for the create new action (default: "Create new") */
8484
createNewLabel?: string;
85+
86+
/** Additional lines of overhead from wrapper components (e.g., tab headers) */
87+
additionalOverhead?: number;
8588
}
8689

8790
export interface ResourcePickerProps<T> {
@@ -134,7 +137,11 @@ export function ResourcePicker<T>({
134137

135138
// Calculate overhead for viewport height
136139
// Matches list pages: breadcrumb(4) + table chrome(4) + stats(2) + nav tips(2) + buffer(1) = 13
137-
const overhead = 13 + search.getSearchOverhead() + extraOverhead;
140+
const overhead =
141+
13 +
142+
search.getSearchOverhead() +
143+
extraOverhead +
144+
(config.additionalOverhead || 0);
138145
const { viewportHeight, terminalWidth } = useViewportHeight({
139146
overhead,
140147
minHeight: 5,

src/screens/ObjectDetailScreen.tsx

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ import {
1515
import { getClient } from "../utils/client.js";
1616
import {
1717
ResourceDetailPage,
18-
formatTimestamp,
1918
type DetailSection,
2019
type ResourceOperation,
2120
} from "../components/ResourceDetailPage.js";
2221
import {
2322
getObject,
2423
deleteObject,
24+
buildObjectDetailFields,
2525
formatFileSize,
2626
} from "../services/objectService.js";
2727
import { useResourceDetail } from "../hooks/useResourceDetail.js";
@@ -175,68 +175,15 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) {
175175
// Build detail sections
176176
const detailSections: DetailSection[] = [];
177177

178-
// Basic details section
179-
const basicFields = [];
180-
if (storageObject.content_type) {
181-
basicFields.push({
182-
label: "Content Type",
183-
value: storageObject.content_type,
184-
});
185-
}
186-
if (storageObject.size_bytes !== undefined) {
187-
basicFields.push({
188-
label: "Size",
189-
value: formatFileSize(storageObject.size_bytes),
190-
});
191-
}
192-
if (storageObject.state) {
193-
basicFields.push({
194-
label: "State",
195-
value: storageObject.state,
196-
});
197-
}
198-
if (storageObject.is_public !== undefined) {
199-
basicFields.push({
200-
label: "Public",
201-
value: storageObject.is_public ? "Yes" : "No",
202-
});
203-
}
204-
if (storageObject.create_time_ms) {
205-
basicFields.push({
206-
label: "Created",
207-
value: formatTimestamp(storageObject.create_time_ms),
208-
});
209-
}
210-
211-
// TTL / Expires - show remaining time before auto-deletion
212-
if (storageObject.delete_after_time_ms) {
213-
const now = Date.now();
214-
const remainingMs = storageObject.delete_after_time_ms - now;
215-
216-
let ttlValue: string;
217-
let ttlColor = colors.text;
218-
219-
if (remainingMs <= 0) {
220-
ttlValue = "Expired";
221-
ttlColor = colors.error;
222-
} else {
223-
const remainingMinutes = Math.floor(remainingMs / 60000);
224-
if (remainingMinutes < 60) {
225-
ttlValue = `${remainingMinutes}m remaining`;
226-
ttlColor = remainingMinutes < 10 ? colors.warning : colors.text;
227-
} else {
228-
const hours = Math.floor(remainingMinutes / 60);
229-
const mins = remainingMinutes % 60;
230-
ttlValue = `${hours}h ${mins}m remaining`;
231-
}
232-
}
233-
234-
basicFields.push({
235-
label: "Expires",
236-
value: <Text color={ttlColor}>{ttlValue}</Text>,
237-
});
238-
}
239-
178+
// Basic details section — reuse shared field builder
179+
const colorMap: Record<string, string> = {
180+
error: colors.error,
181+
warning: colors.warning,
182+
};
183+
const basicFields = buildObjectDetailFields(storageObject).map((f) => ({
184+
...f,
185+
color: f.color ? (colorMap[f.color] ?? f.color) : undefined,
186+
}));
240187
if (basicFields.length > 0) {
241188
detailSections.push({
242189
title: "Details",

src/services/objectService.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Object Service - Handles all storage object API calls
33
*/
44
import { getClient } from "../utils/client.js";
5+
import { formatTimestamp } from "../utils/time.js";
56
import type { StorageObjectView } from "../store/objectStore.js";
67

78
export interface ListObjectsOptions {
@@ -118,6 +119,65 @@ export async function deleteObject(id: string): Promise<void> {
118119
await client.objects.delete(id);
119120
}
120121

122+
export interface ObjectDetailField {
123+
label: string;
124+
value: string;
125+
color?: string;
126+
}
127+
128+
/**
129+
* Build standard detail fields for a storage object.
130+
* Shared between ObjectDetailScreen and AgentDetailScreen.
131+
*/
132+
export function buildObjectDetailFields(
133+
obj: StorageObjectView,
134+
): ObjectDetailField[] {
135+
const fields: ObjectDetailField[] = [];
136+
137+
if (obj.content_type) {
138+
fields.push({ label: "Content Type", value: obj.content_type });
139+
}
140+
if (obj.size_bytes !== undefined && obj.size_bytes !== null) {
141+
fields.push({ label: "Size", value: formatFileSize(obj.size_bytes) });
142+
}
143+
if (obj.state) {
144+
fields.push({ label: "State", value: obj.state });
145+
}
146+
if (obj.is_public !== undefined) {
147+
fields.push({ label: "Public", value: obj.is_public ? "Yes" : "No" });
148+
}
149+
if (obj.create_time_ms) {
150+
fields.push({
151+
label: "Created",
152+
value: formatTimestamp(obj.create_time_ms) ?? "",
153+
});
154+
}
155+
if (obj.delete_after_time_ms) {
156+
const remainingMs = obj.delete_after_time_ms - Date.now();
157+
if (remainingMs <= 0) {
158+
fields.push({ label: "Expires", value: "Expired", color: "error" });
159+
} else {
160+
const remainingMinutes = Math.floor(remainingMs / 60000);
161+
if (remainingMinutes < 60) {
162+
fields.push({
163+
label: "Expires",
164+
value: `${remainingMinutes}m remaining`,
165+
color: remainingMinutes < 10 ? "warning" : undefined,
166+
});
167+
} else {
168+
const hours = Math.floor(remainingMinutes / 60);
169+
const mins = remainingMinutes % 60;
170+
fields.push({
171+
label: "Expires",
172+
value: `${hours}h ${mins}m remaining`,
173+
});
174+
}
175+
}
176+
}
177+
178+
return fields;
179+
}
180+
121181
/**
122182
* Format file size in human-readable format
123183
*/
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
formatFileSize,
3+
buildObjectDetailFields,
4+
} from "../../../src/services/objectService.js";
5+
import type { StorageObjectView } from "../../../src/store/objectStore.js";
6+
7+
describe("formatFileSize", () => {
8+
it("returns Unknown for null", () => {
9+
expect(formatFileSize(null)).toBe("Unknown");
10+
});
11+
12+
it("returns Unknown for undefined", () => {
13+
expect(formatFileSize(undefined)).toBe("Unknown");
14+
});
15+
16+
it("formats bytes", () => {
17+
expect(formatFileSize(500)).toBe("500 B");
18+
});
19+
20+
it("formats kilobytes", () => {
21+
expect(formatFileSize(1024)).toBe("1.00 KB");
22+
});
23+
24+
it("formats megabytes", () => {
25+
expect(formatFileSize(1024 * 1024)).toBe("1.00 MB");
26+
});
27+
28+
it("formats gigabytes", () => {
29+
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.00 GB");
30+
});
31+
32+
it("formats zero bytes", () => {
33+
expect(formatFileSize(0)).toBe("0 B");
34+
});
35+
});
36+
37+
describe("buildObjectDetailFields", () => {
38+
const baseObject: StorageObjectView = {
39+
id: "obj_123",
40+
name: "test.txt",
41+
content_type: "text/plain",
42+
create_time_ms: 1700000000000,
43+
state: "READY",
44+
size_bytes: 1024,
45+
};
46+
47+
it("includes content type", () => {
48+
const fields = buildObjectDetailFields(baseObject);
49+
expect(fields.find((f) => f.label === "Content Type")?.value).toBe(
50+
"text/plain",
51+
);
52+
});
53+
54+
it("includes formatted size", () => {
55+
const fields = buildObjectDetailFields(baseObject);
56+
expect(fields.find((f) => f.label === "Size")?.value).toBe("1.00 KB");
57+
});
58+
59+
it("includes state", () => {
60+
const fields = buildObjectDetailFields(baseObject);
61+
expect(fields.find((f) => f.label === "State")?.value).toBe("READY");
62+
});
63+
64+
it("includes public field when set", () => {
65+
const fields = buildObjectDetailFields({ ...baseObject, is_public: true });
66+
expect(fields.find((f) => f.label === "Public")?.value).toBe("Yes");
67+
});
68+
69+
it("includes created timestamp", () => {
70+
const fields = buildObjectDetailFields(baseObject);
71+
expect(fields.find((f) => f.label === "Created")).toBeDefined();
72+
});
73+
74+
it("includes expires when delete_after_time_ms is set", () => {
75+
const future = Date.now() + 3600000; // 1 hour from now
76+
const fields = buildObjectDetailFields({
77+
...baseObject,
78+
delete_after_time_ms: future,
79+
});
80+
const expiresField = fields.find((f) => f.label === "Expires");
81+
expect(expiresField).toBeDefined();
82+
expect(expiresField?.value).toContain("remaining");
83+
});
84+
85+
it("shows Expired with error color for past delete_after_time_ms", () => {
86+
const past = Date.now() - 1000;
87+
const fields = buildObjectDetailFields({
88+
...baseObject,
89+
delete_after_time_ms: past,
90+
});
91+
const expiresField = fields.find((f) => f.label === "Expires");
92+
expect(expiresField?.value).toBe("Expired");
93+
expect(expiresField?.color).toBe("error");
94+
});
95+
96+
it("shows warning color when expiry is under 10 minutes", () => {
97+
const soon = Date.now() + 5 * 60000; // 5 minutes from now
98+
const fields = buildObjectDetailFields({
99+
...baseObject,
100+
delete_after_time_ms: soon,
101+
});
102+
const expiresField = fields.find((f) => f.label === "Expires");
103+
expect(expiresField?.color).toBe("warning");
104+
});
105+
});

0 commit comments

Comments
 (0)