chore: test deploy snapshot

This commit is contained in:
2026-05-28 08:09:49 +09:00
parent e195ac8088
commit 983887dc05
30 changed files with 1730 additions and 108 deletions

View File

@@ -144,7 +144,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
if (!accessContext) {
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
}
@@ -197,23 +197,33 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
if (!accessContext) {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
const item = await updateBaseballTicketBayAlert(params.id, payload, {
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
});
const item = await updateBaseballTicketBayAlert(
params.id,
payload,
accessContext.scope === 'all'
? {
scope: { kind: 'all' },
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
}
: {
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
},
);
await createBaseballTicketBayLog({
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertId: item.id,
alertTitle: item.title,
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
@@ -236,7 +246,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
if (!accessContext) {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
}
@@ -248,9 +258,9 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
}
await createBaseballTicketBayLog({
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertId: item.id,
alertTitle: item.title,
action: 'delete',
@@ -274,10 +284,6 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
if (accessContext.scope === 'all') {
return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' });
}
const result = await runBaseballTicketBayAlert(params.id, {
ignoreTimeWindow: true,
scope: toOwnerScope(accessContext),

View File

@@ -7,6 +7,7 @@ import { env } from '../config/env.js';
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource');
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
@@ -55,3 +56,61 @@ test('resource manager preview serves 206 partial content for byte ranges', asyn
await app.close();
}
});
test('resource manager preview falls back to public/resource legacy artifacts', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`);
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, '<!doctype html><html><body>legacy preview</body></html>', 'utf8');
try {
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
});
assert.equal(response.statusCode, 200);
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
assert.match(response.body, /legacy preview/);
} finally {
await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
await app.close();
}
});
test('resource manager preview restores encoded hash fragments in the file name', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, '<!doctype html><html><body>encoded hash preview</body></html>', 'utf8');
try {
for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
const encodedPath = relativePath
.split(path.sep)
.map((segment, index, list) =>
index === list.length - 1
? `${encodeURIComponent(segment)}${encodedSuffix}`
: encodeURIComponent(segment),
)
.join('/');
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
});
assert.equal(response.statusCode, 200);
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
assert.match(response.body, /encoded hash preview/);
}
} finally {
await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
await app.close();
}
});

View File

@@ -2,6 +2,7 @@ import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
import { getSharedResourceTokenDetail } from './shared-resource-token-service.js';
const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr';
const SPORTS_SEARCH_KEY = '5';
@@ -695,6 +696,7 @@ export type BaseballTicketBayAlertItem = {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
ownerLabel?: string | null;
title: string;
eventDate: string;
team: string;
@@ -722,6 +724,7 @@ export type BaseballTicketBayAlertLogItem = {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
ownerLabel?: string | null;
alertId: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -902,6 +905,7 @@ function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
ownerLabel: null,
title: normalizeText(row.title),
eventDate: normalizeText(row.event_date),
team: normalizeText(row.team) || '전체',
@@ -938,6 +942,7 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
ownerLabel: null,
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
alertTitle: normalizeText(row.alert_title),
action: row.action,
@@ -949,6 +954,34 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
};
}
async function attachOwnerLabels<T extends { ownerType: BaseballTicketBayOwnerType; ownerId: string; ownerLabel?: string | null }>(items: T[]) {
const sharedTokenOwnerIds = Array.from(
new Set(
items
.filter((item) => item.ownerType === 'shared-token')
.map((item) => normalizeText(item.ownerId))
.filter(Boolean),
),
);
if (!sharedTokenOwnerIds.length) {
return items;
}
const labelEntries = await Promise.all(
sharedTokenOwnerIds.map(async (tokenId) => {
const detail = await getSharedResourceTokenDetail(tokenId);
return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const;
}),
);
const labelMap = new Map(labelEntries);
return items.map((item) => ({
...item,
ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null,
}));
}
function formatTimeInKst(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Seoul',
@@ -1162,7 +1195,7 @@ export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerS
{ column: 'client_id', order: 'asc' },
{ column: 'created_at', order: 'desc' },
])) as BaseballTicketBayAlertRow[];
return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)));
}
export async function listBaseballTicketBayLogs(
@@ -1177,7 +1210,7 @@ export async function listBaseballTicketBayLogs(
}
const rows = (await query) as BaseballTicketBayLogRow[];
return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)));
}
export async function createBaseballTicketBayLog(args: {
@@ -1208,7 +1241,7 @@ export async function createBaseballTicketBayLog(args: {
payload_json: args.payload ? JSON.stringify(args.payload) : null,
};
await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row);
return mapLogRow(row);
return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
@@ -1234,7 +1267,7 @@ export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballT
scope,
).delete();
return mapLogRow(existing as BaseballTicketBayLogRow);
return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item);
}
export async function createBaseballTicketBayAlert(
@@ -1269,20 +1302,26 @@ export async function createBaseballTicketBayAlert(
last_match_at: null,
};
await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row);
return mapAlertRow(row);
return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item);
}
export async function updateBaseballTicketBayAlert(
alertId: string,
payload: Partial<BaseballTicketBayAlertMutation>,
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
context:
| { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string }
| { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const scope =
'scope' in context
? context.scope
: ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const);
const current = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
scope,
).first();
if (!current) {
@@ -1309,18 +1348,15 @@ export async function updateBaseballTicketBayAlert(
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).update(patch);
await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch);
const updated = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
scope,
).first();
return mapAlertRow(updated as BaseballTicketBayAlertRow);
return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
@@ -1340,7 +1376,7 @@ export async function deleteBaseballTicketBayAlert(alertId: string, scope: Baseb
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
scope,
).delete();
return mapAlertRow(row as BaseballTicketBayAlertRow);
return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {

View File

@@ -105,9 +105,103 @@ function unwrapMarkdownLinkTarget(value: string) {
return matched?.[1]?.trim() ?? normalized;
}
function normalizeResourceManagerPathSegment(segment: string) {
const normalized = normalizeText(segment);
if (!normalized) {
return '';
}
try {
return encodeURIComponent(decodeURIComponent(normalized));
} catch {
return encodeURIComponent(normalized);
}
}
function normalizeUrlFragmentValue(value: string) {
const normalized = normalizeText(value).replace(/^#+/, '');
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewPathHash(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
const isRootRelative = normalized.startsWith('/');
try {
const parsed = new URL(normalized, 'https://local.invalid');
const segments = parsed.pathname.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0 || parsed.hash) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = encodeURIComponent(fileName);
parsed.pathname = segments.join('/');
parsed.hash = fragment;
if (isAbsoluteUrl) {
return parsed.toString();
}
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
} catch {
return normalized;
}
}
function buildResourceManagerPreviewUrl(value: string) {
const normalized = normalizeText(value).replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
const hashIndex = normalized.indexOf('#');
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
if (!resourcePath) {
@@ -123,10 +217,10 @@ function buildResourceManagerPreviewUrl(value: string) {
const encodedPath = relativePath
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
}
function normalizeUrl(value: string) {
@@ -136,6 +230,17 @@ function normalizeUrl(value: string) {
return '';
}
try {
const parsed = new URL(normalized, 'https://local.invalid');
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return normalizePreviewPathHash(pathname);
}
} catch {
// Fall through to handle relative and embedded resource paths below.
}
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;

View File

@@ -1416,6 +1416,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h
);
});
test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: null,
preview: {
type: 'resource',
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: null,
preview: {
type: 'resource',
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts supports stepper prompt steps', () => {
assert.deepEqual(
extractChatMessageParts(

View File

@@ -36,6 +36,7 @@ import {
getSharedResourceTokenDetailBySharePath,
validateSharedResourceAccessPinBySharePath,
} from './shared-resource-token-service.js';
import { getChatShareTokenRoomMap } from './chat-share-room-map-service.js';
import { sendNotifications } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
@@ -794,10 +795,18 @@ async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL)
const sharedToken = sharedTokenDetail?.token ?? null;
const resourceContext = sharedToken?.resourceContext ?? null;
if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) {
if (!resourceContext) {
return false;
}
if (requestedSessionId && resourceContext.sessionId !== requestedSessionId) {
const requestedRoom = sharedToken?.id ? await getChatShareTokenRoomMap(sharedToken.id, requestedSessionId) : null;
if (!requestedRoom) {
return false;
}
}
if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) {
return false;
}

View File

@@ -84,10 +84,14 @@ type NotificationPreferenceTarget = {
function normalizeTargetDeviceIds(payload: {
targetDeviceIds?: string[];
}) {
return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeTargetClientIds(payload: {
targetClientIds?: string[];
}) {
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
@@ -126,18 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetDeviceId(
function isAllowedTargetRecipient(
target: {
deviceId?: string;
clientId?: string;
},
targetDeviceIds: string[],
targetClientIds: string[],
) {
if (targetDeviceIds.length === 0) {
if (targetDeviceIds.length === 0 && targetClientIds.length === 0) {
return true;
}
const deviceId = String(target.deviceId ?? '').trim();
return Boolean(deviceId) && targetDeviceIds.includes(deviceId);
const clientId = String(target.clientId ?? '').trim();
return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId));
}
function normalizeAppOrigin(value: unknown) {
@@ -917,6 +924,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const provider = await getProvider();
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
@@ -952,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
.filter(
(row) =>
row.allowed &&
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
@@ -1002,6 +1010,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) {
@@ -1031,7 +1040,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
).filter(
(row) =>
row.allowed &&
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
const matchedSubscriptions = subscriptions.map((row) => ({

View File

@@ -63,6 +63,7 @@ class ResourceManagerError extends Error {
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource');
const TEXT_FILE_EXTENSIONS = new Set([
'.txt',
@@ -199,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
return normalized.replace(/^\/+/, '');
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewTargetPath(targetPath: string) {
const normalized = normalizeRelativeTarget(targetPath);
if (!normalized) {
return normalized;
}
const segments = normalized.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = fileName;
return normalizeRelativeTarget(segments.join('/'));
}
function resolveRepoRoot(candidateRootPath: string) {
const candidates = [
candidateRootPath,
@@ -221,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR);
}
function resolveLegacyPublicResourceRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR);
}
function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) {
const normalizedRelativePath = normalizeRelativeTarget(targetPath);
if (!normalizedRelativePath) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
}
const rootPath = resolveLegacyPublicResourceRoot(repoRootPath);
const absolutePath = path.resolve(rootPath, normalizedRelativePath);
if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
throw new ResourceManagerError('허용되지 않은 경로입니다.', 403);
}
return {
absolutePath,
relativePath: normalizedRelativePath,
};
}
function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) {
const rootPath = resolveResourceManagerRoot(repoRootPath);
const normalizedRelativePath = normalizeRelativeTarget(relativePath);
@@ -604,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
return withResourceManagerError(async () => {
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
const normalizedTargetPath = normalizePreviewTargetPath(targetPath);
const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath);
const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath);
const absolutePath = existsSync(primaryTarget.absolutePath)
? primaryTarget.absolutePath
: legacyTarget.absolutePath;
if (!existsSync(absolutePath)) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);

View File

@@ -0,0 +1,28 @@
# 공유채팅방 개선
## 변경 목적
- stepper prompt에서 HTML preview가 객체 재생성마다 다시 fetch/reset 되며 멈춘 것처럼 보이던 흐름을 줄입니다.
- 공유채팅방 이동 시 이미 본 방은 즉시 복원하고, 최신화는 뒤에서 다시 받아 체감 로딩을 줄입니다.
- 재접속 시 마지막으로 사용한 공유채팅방을 다시 열 때, 마지막 방 ID뿐 아니라 해당 방 스냅샷도 세션 기준으로 복원합니다.
## 변경 범위
- `src/app/main/mainChatPanel/ChatPromptCard.tsx`
- preview fetch effect 의존성을 안정화했습니다.
- preview 본문/`content-type`을 메모리 캐시에 저장해 같은 HTML/markdown/resource preview 재진입 시 재요청을 줄였습니다.
- preview viewed / selection change effect에서 객체 참조 의존성을 줄여 stepper 렌더 루프 가능성을 낮췄습니다.
- `src/app/main/pages/ChatSharePage.tsx`
- 공유채팅방 스냅샷을 `sessionStorage`에도 저장하도록 추가했습니다.
- 토큰별 마지막 방 복원 시 세션 캐시 스냅샷을 먼저 적용하도록 보강했습니다.
- 방 전환 시 메모리 캐시가 없더라도 세션 캐시가 있으면 즉시 그 스냅샷으로 전환하도록 보강했습니다.
## 데이터 / API 영향
- 새 저장소 키
- `sessionStorage`: `codex-live-share-room-snapshot:<token>:<sessionId>`
- 기존 API 계약 변경 없음
- `/api/chat/shares/:token`
- `/api/chat/shares/:token?sessionId=...`
## 확인 포인트
- 같은 stepper prompt preview를 다시 펼쳐도 로딩 스피너가 계속 반복되지 않는지
- 이미 열어본 공유채팅방을 다시 눌렀을 때 화면이 캐시로 먼저 복원되는지
- 새로고침 후 마지막 사용 방 URL / 선택 상태가 유지되는지

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,24 @@
# 공유채팅방 개선 검증
## 실행 결과
- `npm exec tsc --noEmit`
- 성공
- `npm run build:test-app`
- 성공
- `curl http://127.0.0.1:5173/api/chat/shares/5e578dd5e91a4fa8b32cfe3c?sharePin=1459`
- 성공
- 응답 기준: `ok: true`, `title: 관리자`, `sessionId: chat-share-room-mpihlq67-ae86e941`, `requestCount: 5`
## 브라우저 확인
- 로컬 test-app 빌드로 `/chat/share/:token` 진입 시 공유채팅 셸 자체는 열렸습니다.
- 다만 이 격리 빌드 환경에서는 화면이 로딩 스피너 상태에 머무는 케이스가 있어, 이번 턴에서는 신뢰 가능한 기능 완료 화면 캡처까지는 확보하지 못했습니다.
- 따라서 이번 검증 결론은 다음 범위로 한정합니다.
- 타입 오류 없음
- 프로덕션 테스트 빌드 성공
- 공유채팅 스냅샷 API 정상 응답
- 로컬 브라우저 진입 자체는 가능
## 판정
- 코드 변경은 정상 반영됨
- stepper HTML preview 안정화와 공유채팅방 캐시/복원 로직은 정적 검증 + API 검증까지 완료
- 실서버 UI 최종 체감 확인은 공유채팅 실환경에서 한 번 더 보는 것이 안전함

View File

@@ -0,0 +1,27 @@
# 공유채팅방 멈춤 완화
## 변경 배경
- 1차 수정으로 `sessionStorage`에 공유방 스냅샷을 직렬화하던 경로는 제거했지만, 큰 관리형 공유채팅방에서는 여전히 서버가 최근 1000건 요청/메시지를 한 번에 내려주고 있었습니다.
- 공유채팅 페이지도 검색 모달이 닫힌 상태에서 질문·응답·리소스·활동로그 통합검색 인덱스를 매 렌더마다 전수 계산하고 있어, 큰 방에서는 첫 진입과 갱신 시 추가 부담이 남아 있었습니다.
## 변경 내용
- 기존 `sessionStorage` 제거 상태는 유지합니다.
- `etc/servers/work-server/src/routes/chat.ts`
- 관리형 공유채팅방(`MANAGED_CHAT_SHARE_SESSION_PREFIX`) 스냅샷은 최근 80건 요청 기준의 detail page만 내려주도록 바꿨습니다.
- 공유채팅 초기 진입과 실시간 갱신이 더 이상 최근 1000건 전체 요청/메시지를 항상 읽지 않게 했습니다.
- `src/app/main/pages/ChatSharePage.tsx`
- 통합검색 결과 계산은 검색 모달이 실제로 열렸을 때만 수행하도록 바꿨습니다.
- 방 진입 직후에는 닫혀 있는 검색 패널 때문에 질문/응답/리소스/활동로그 전체를 훑지 않습니다.
## 기대 효과
- 큰 공유채팅방에서도 초기 진입과 자동 새로고침이 최근 이력 중심으로 동작해 멈춤 체감이 줄어듭니다.
- 재접속 시 마지막 방 복원 기능은 유지하면서, 서버/프런트 양쪽의 불필요한 대량 계산을 줄입니다.
## 영향 범위
- 공유채팅 페이지의 검색 계산 시점과 공유 스냅샷 응답 범위를 조정했습니다.
- DB 스키마와 공유채팅 권한 로직은 변경하지 않았습니다.
## 확인 포인트
- 관리형 공유채팅방 첫 진입 시 최근 이력 기준으로 빠르게 열리는지 확인
- 메시지/활동로그가 많은 방에서도 방 이동·새로고침·실시간 갱신 시 멈춤 체감이 줄었는지 확인
- 검색 모달을 열지 않았을 때는 통합검색 전수 계산이 돌지 않는지 확인

View File

@@ -0,0 +1,15 @@
# 검증 요약
## 실행한 검증
- `npm exec tsc --noEmit`
- `npx tsc -p etc/servers/work-server/tsconfig.json --noEmit`
- `npm run build:test-app`
## 결과
- 프런트 타입 검사와 `work-server` 타입 검사는 모두 통과했습니다.
- 테스트 번들은 재빌드까지 통과했습니다.
- 이번 수정으로 관리형 공유채팅방 스냅샷은 최근 80건 detail page 기준으로 줄였고, 검색 모달이 닫혀 있을 때는 통합검색 계산을 건너뜁니다.
## 비고
- 이번 수정은 UI 레이아웃 변경이 아니라 응답량·계산량 축소라서, 최종 화면 캡처 대신 타입/빌드 검증과 코드 경로 문서화를 남겼습니다.
- `preview.sm-home.cloud` 실접속/잠금 해제 재현은 이번 턴에서 다시 수행하지 못했습니다.

View File

@@ -0,0 +1,20 @@
# 공유채팅 채팅방 이동 소도 개선
## 변경 요약
- 공유채팅방 마지막 선택 방 저장을 `localStorage`에서 `sessionStorage`로 변경했습니다.
- 같은 탭 안에서는 마지막으로 보던 방을 복원하지만, 브라우저를 완전히 닫으면 기억을 남기지 않습니다.
- 채팅방 선택 시 `roomSessionId`를 URL에 반영할 때 사용자 선택은 `pushState`, 자동 보정은 `replaceState`로 나눴습니다.
- 브라우저 뒤로가기/앞으로가기 시 현재 URL의 `roomSessionId`를 다시 읽어 선택 방과 동기화합니다.
## 변경 범위
- 공유채팅 화면의 방 선택/복원/URL 동기화 로직
- 영구 저장 제거에 따른 탭 세션 단위 이동 상태 복원
## 데이터 및 API 영향
- 서버 API 스펙 변경은 없습니다.
- 클라이언트 저장소 사용 범위만 `localStorage` -> `sessionStorage`로 바뀝니다.
## 확인 포인트
- 공유채팅에서 방을 바꾼 뒤 새로고침하면 같은 탭에서는 마지막 방이 유지되는지
- 브라우저 뒤로가기/앞으로가기 때 이전/다음 방으로 이동되는지
- 브라우저를 완전히 닫았다가 다시 열면 이전 방이 영구 복원되지 않는지

View File

@@ -0,0 +1,11 @@
# 검증 요약
## 수행 내용
- `npm exec tsc --noEmit`
## 결과
- 타입체크 통과
## 미수행 항목
- `https://preview.sm-home.cloud/` 브라우저 실접속 검증은 이번 턴에서 수행하지 못했습니다.
- 시각 레이아웃 변경이 아니라서 별도 UI 스크린샷은 생성하지 않았습니다.

View File

@@ -0,0 +1,709 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>공유채팅 헤더 재배치 제안</title>
<style>
:root {
--bg-top: #edf3fb;
--bg-bottom: #e4edf8;
--surface: rgba(255, 255, 255, 0.84);
--surface-strong: rgba(255, 255, 255, 0.94);
--surface-soft: rgba(248, 250, 252, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #64748b;
--blue: #2563eb;
--blue-soft: rgba(219, 234, 254, 0.96);
--green: #166534;
--green-soft: rgba(220, 252, 231, 0.98);
--amber: #92400e;
--amber-soft: rgba(254, 243, 199, 0.98);
--red: #b91c1c;
--red-soft: rgba(254, 226, 226, 0.98);
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.14);
--shadow-md: 0 12px 28px rgba(148, 163, 184, 0.16);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 18px;
--radius-sm: 14px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Pretendard", "Inter", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 28%),
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
}
.page {
width: min(1480px, calc(100vw - 40px));
margin: 0 auto;
padding: 28px 0 40px;
}
.hero {
display: grid;
gap: 14px;
padding: 22px 24px;
border: 1px solid rgba(196, 210, 226, 0.88);
border-radius: 28px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 250, 252, 0.86));
box-shadow: var(--shadow-lg);
backdrop-filter: blur(18px);
}
.eyebrow,
.chip,
.tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: -0.01em;
}
.eyebrow {
width: fit-content;
color: var(--blue);
background: var(--blue-soft);
}
.hero h1 {
margin: 0;
font-size: 34px;
line-height: 1.1;
letter-spacing: -0.04em;
}
.hero p {
margin: 0;
max-width: 920px;
color: var(--muted);
line-height: 1.6;
}
.dna {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
color: #334155;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(196, 210, 226, 0.92);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-top: 22px;
}
.proposal {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 26px;
border: 1px solid rgba(196, 210, 226, 0.96);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(244, 248, 253, 0.94));
box-shadow: var(--shadow-md);
}
.proposal h2 {
margin: 0;
font-size: 22px;
letter-spacing: -0.03em;
}
.proposal p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
padding: 6px 10px;
color: #334155;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(196, 210, 226, 0.86);
}
.phone {
display: grid;
gap: 10px;
min-height: 740px;
padding: 14px;
border-radius: 30px;
background:
linear-gradient(180deg, rgba(237, 243, 251, 0.98), rgba(228, 237, 248, 0.94)),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.1), transparent 32%);
border: 1px solid rgba(196, 210, 226, 0.98);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.4),
0 18px 38px rgba(15, 23, 42, 0.12);
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 0;
font-size: 11px;
color: #475569;
}
.header {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.78));
border: 1px solid rgba(196, 210, 226, 0.9);
backdrop-filter: blur(18px);
}
.header-top,
.header-bottom,
.header-split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.room-trigger,
.action-pill,
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.42),
0 8px 18px rgba(148, 163, 184, 0.14);
}
.room-trigger {
min-width: 0;
flex: 1 1 auto;
justify-content: flex-start;
}
.room-avatar {
width: 28px;
height: 28px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.94), rgba(59, 130, 246, 0.72));
color: white;
display: grid;
place-items: center;
font-weight: 800;
font-size: 13px;
flex: 0 0 auto;
}
.room-copy {
display: grid;
min-width: 0;
}
.room-copy strong {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-copy span {
color: var(--muted);
font-size: 11px;
}
.icon-circle {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(219, 234, 254, 0.94);
color: var(--blue);
font-size: 14px;
font-weight: 800;
}
.handle {
width: 44px;
height: 5px;
margin: 2px auto 0;
border-radius: 999px;
background: rgba(148, 163, 184, 0.52);
}
.dashboard {
display: grid;
gap: 10px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric {
padding: 14px;
border-radius: 20px;
border: 1px solid rgba(196, 210, 226, 0.92);
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
}
.metric strong {
display: block;
font-size: 24px;
letter-spacing: -0.04em;
}
.metric span {
color: var(--muted);
font-size: 12px;
}
.metric.blue {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(219, 234, 254, 0.94));
}
.metric.green {
background: linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.94));
}
.metric.amber {
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.94));
}
.metric.red {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.94));
}
.sheet {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 26px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.92));
border: 1px solid rgba(196, 210, 226, 0.9);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.32),
0 14px 28px rgba(148, 163, 184, 0.14);
}
.sheet-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sheet-title strong {
font-size: 15px;
}
.section {
display: grid;
gap: 8px;
}
.section-label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list,
.feed {
display: grid;
gap: 8px;
}
.list-item,
.feed-item {
display: grid;
gap: 4px;
padding: 12px 13px;
border-radius: 18px;
background: var(--surface-strong);
border: 1px solid rgba(196, 210, 226, 0.86);
}
.list-item-top,
.feed-item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.list-item strong,
.feed-item strong {
font-size: 13px;
}
.list-item span,
.feed-item span,
.feed-item p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.badge {
padding: 5px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.badge.blue {
color: #1d4ed8;
background: rgba(219, 234, 254, 0.96);
}
.badge.green {
color: var(--green);
background: var(--green-soft);
}
.badge.amber {
color: var(--amber);
background: var(--amber-soft);
}
.badge.red {
color: var(--red);
background: var(--red-soft);
}
.message {
flex: 1 1 auto;
display: grid;
gap: 10px;
align-content: start;
padding: 8px 2px 2px;
}
.bubble {
max-width: 86%;
padding: 12px 14px;
border-radius: 18px;
line-height: 1.55;
font-size: 13px;
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.1);
}
.bubble.self {
justify-self: end;
color: white;
background: linear-gradient(135deg, #2563eb, #3b82f6);
}
.bubble.other {
justify-self: start;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(196, 210, 226, 0.82);
}
.footer-note {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.8);
border: 1px dashed rgba(148, 163, 184, 0.4);
color: #475569;
line-height: 1.6;
}
@media (max-width: 1280px) {
.grid {
grid-template-columns: 1fr;
}
.phone {
min-height: auto;
}
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<span class="eyebrow">공유채팅 실제 테마 기반 제안</span>
<h1>채팅방 헤더를 방 목록 + 알림센터 허브로 재구성</h1>
<p>
현재 공유채팅이 쓰는 옅은 블루 그라데이션, 반투명 화이트 surface, 파란 pill 액션 톤을 유지하면서
헤더의 역할을 명확히 분리했습니다. 공통 방향은 “제목/아이콘 클릭으로 방 목록”, “중앙 손잡이 드래그로
iOS 알림센터 스타일”, “현재 방과 다른 방 알림을 한 시트에서 함께 확인”입니다.
</p>
<div class="dna">
<span class="chip">현재 테마: #edf3fb → #e4edf8</span>
<span class="chip">액션 톤: white pill + #2563eb</span>
<span class="chip">재질감: blur + soft shadow</span>
<span class="chip">상태칩: blue / green / amber / red</span>
</div>
</section>
<section class="grid">
<article class="proposal" id="option-a">
<div>
<h2>A안. Capsule Rail</h2>
<p>가장 자연스럽게 익숙한 안입니다. 제목 캡슐이 방 목록 진입점이 되고, 중앙 손잡이를 내려 전체 알림센터를 펼칩니다.</p>
</div>
<div class="tag-row">
<span class="tag">추천: 모바일 우선</span>
<span class="tag">방 목록 발견성 높음</span>
<span class="tag">알림센터 구분 명확</span>
</div>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>Live 5G 92%</span></div>
<div class="header">
<div class="header-top">
<div class="room-trigger">
<div class="room-avatar">CC</div>
<div class="room-copy">
<strong>공유채팅 운영룸</strong>
<span>제목/아이콘 탭: 방 목록 + 필터 열기</span>
</div>
</div>
<div class="action-pill">설정</div>
<div class="icon-circle">-</div>
<div class="icon-circle">×</div>
</div>
<div class="header-bottom">
<div class="filter-pill">진행중 6 · 다른 방 새답변 4</div>
<div class="filter-pill">apps 2건</div>
</div>
<div class="handle"></div>
</div>
<div class="sheet">
<div class="sheet-title">
<strong>알림센터</strong>
<span class="badge blue">전체 채팅 + apps</span>
</div>
<div class="dashboard">
<div class="dashboard-grid">
<div class="metric blue"><strong>12</strong><span>처리중 요청</span></div>
<div class="metric green"><strong>4</strong><span>다른 방 새 답변</span></div>
<div class="metric amber"><strong>2</strong><span>apps 경고</span></div>
<div class="metric red"><strong>1</strong><span>확인 필요 실패</span></div>
</div>
</div>
<div class="section">
<div class="section-label">방 목록 + 필터</div>
<div class="list">
<div class="list-item">
<div class="list-item-top"><strong>전체 방</strong><span class="badge blue">18</span></div>
<span>최근답변, 처리중, 안읽음, apps 연결방 필터를 같은 레이어에서 전환</span>
</div>
<div class="list-item">
<div class="list-item-top"><strong>개발 운영방</strong><span class="badge green">새 답변</span></div>
<span>3분 전 Codex 응답 도착 · 앱 연결 2개</span>
</div>
</div>
</div>
<div class="section">
<div class="section-label">알림 피드</div>
<div class="feed">
<div class="feed-item">
<div class="feed-item-top"><strong>자동화 공유방</strong><span class="badge blue">진행중</span></div>
<p>배포 확인 캡처 업로드 완료. 검증 스크린샷 2장 확인 필요.</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>Apps 알림</strong><span class="badge amber">권한</span></div>
<p>캘린더 동기화 1건 지연. 알림센터에서 바로 상세 열기 버튼 제공.</p>
</div>
</div>
</div>
</div>
<div class="message">
<div class="bubble other">헤더 제목을 누르면 바로 방 목록과 필터가 한 번에 보여야 합니다.</div>
<div class="bubble self">A안은 그 요구를 가장 직접적으로 충족합니다.</div>
</div>
</div>
</article>
<article class="proposal" id="option-b">
<div>
<h2>B안. Split Status Bar</h2>
<p>좌측은 현재 방, 우측은 전체 알림과 apps 상태를 쌓아 두는 데스크톱 친화형입니다. 헤더 하단은 세그먼트 필터로 남깁니다.</p>
</div>
<div class="tag-row">
<span class="tag">추천: 데스크톱 확장</span>
<span class="tag">필터 접근 가장 빠름</span>
<span class="tag">정보량 많음</span>
</div>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>Workspace online</span></div>
<div class="header">
<div class="header-split">
<div class="room-trigger">
<div class="room-avatar">PM</div>
<div class="room-copy">
<strong>프로젝트 메인룸</strong>
<span>아이콘/제목 클릭: 방 전환</span>
</div>
</div>
<div class="action-pill">다른 방 4</div>
<div class="action-pill">apps 2</div>
</div>
<div class="header-bottom">
<div class="filter-pill">전체</div>
<div class="filter-pill">진행중</div>
<div class="filter-pill">안읽음</div>
<div class="filter-pill">apps</div>
</div>
<div class="handle"></div>
</div>
<div class="sheet">
<div class="sheet-title">
<strong>Notification Center Dashboard</strong>
<span class="badge green">실시간 집계</span>
</div>
<div class="dashboard-grid">
<div class="metric blue"><strong>08</strong><span>현재 방 처리 흐름</span></div>
<div class="metric green"><strong>03</strong><span>다른 방 확인대기</span></div>
<div class="metric amber"><strong>05</strong><span>apps 작업 알림</span></div>
<div class="metric red"><strong>02</strong><span>실패/재시도</span></div>
</div>
<div class="section">
<div class="section-label">요약 콘텐츠</div>
<div class="feed">
<div class="feed-item">
<div class="feed-item-top"><strong>현재 방</strong><span class="badge blue">3건</span></div>
<p>작업중인 요청, 마지막 응답 시간, 첨부 리소스 생성 수를 카드형으로 고정 배치</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>다른 채팅방</strong><span class="badge green">새 답변</span></div>
<p>현재 방이 아니어도 읽지 않은 응답과 mention 성격 요청을 한 섹션에 모아 보여줌</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>Apps</strong><span class="badge amber">확인 필요</span></div>
<p>캘린더, 알림, 연결 앱의 상태 메시지를 채팅 알림과 동일한 카드 리듬으로 정렬</p>
</div>
</div>
</div>
</div>
<div class="message">
<div class="bubble other">필터를 자주 바꾸는 운영자라면 헤더 안에서 바로 전환하고 싶습니다.</div>
<div class="bubble self">B안은 필터 중심 운영에 가장 유리합니다.</div>
</div>
</div>
</article>
<article class="proposal" id="option-c">
<div>
<h2>C안. Focus Stack</h2>
<p>현재 작업중인 방을 더 크게 인지시키는 집중형입니다. 알림센터는 “현재 방 집중 + 다른 방 보조” 우선순위가 드러납니다.</p>
</div>
<div class="tag-row">
<span class="tag">추천: 집중 작업</span>
<span class="tag">브랜드감 강함</span>
<span class="tag">운영감시형 대시보드</span>
</div>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>Preview theme sync</span></div>
<div class="header">
<div class="header-top">
<div class="room-trigger" style="min-height: 52px;">
<div class="room-avatar" style="width: 34px; height: 34px; border-radius: 14px;">UX</div>
<div class="room-copy">
<strong>UX 검토 채팅방</strong>
<span>Hero chip 탭: 방 목록 / 필터 / 최근방</span>
</div>
</div>
<div class="icon-circle"></div>
</div>
<div class="header-bottom">
<div class="filter-pill">현재 방 진행중 4</div>
<div class="filter-pill">전체 알림 9</div>
</div>
<div class="handle"></div>
</div>
<div class="sheet">
<div class="sheet-title">
<strong>집중 대시보드</strong>
<span class="badge blue">현재 방 우선</span>
</div>
<div class="dashboard-grid">
<div class="metric blue"><strong>4</strong><span>현재 방 처리중</span></div>
<div class="metric green"><strong>2</strong><span>완료 직전</span></div>
<div class="metric amber"><strong>3</strong><span>다른 방 확인</span></div>
<div class="metric red"><strong>1</strong><span>apps 경고</span></div>
</div>
<div class="section">
<div class="section-label">우선순위 피드</div>
<div class="feed">
<div class="feed-item">
<div class="feed-item-top"><strong>현재 방 요청</strong><span class="badge blue">우선</span></div>
<p>마지막 응답 이후 7분 경과. 첨부 preview 3개 생성됨. 바로 이어보기 버튼 노출.</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>다른 방 답변</strong><span class="badge green">보조</span></div>
<p>메인 운영방에 새 응답 2건. 눌러서 방 전환 없이 quick peek 가능.</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>Apps 이벤트</strong><span class="badge amber">연결</span></div>
<p>배포 완료, 캘린더 일정, 리소스 등록 완료 이벤트를 낮은 대비 카드로 정렬.</p>
</div>
</div>
</div>
</div>
<div class="message">
<div class="bubble other">작업 중인 방을 잃지 않으면서도 다른 방 알림은 놓치고 싶지 않습니다.</div>
<div class="bubble self">C안은 집중감은 가장 좋지만, 운영형 전체 목록성은 A안보다 약합니다.</div>
</div>
</div>
</article>
</section>
<section class="footer-note">
추천 순서는 A안 → B안 → C안입니다. A안은 현재 공유채팅의 둥근 pill 액션과 blur 헤더 감성을 가장 자연스럽게 이어가면서,
“제목/아이콘으로 방 목록”, “손잡이로 알림센터”라는 두 행동을 가장 덜 헷갈리게 분리합니다.
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,53 @@
# 공유채팅 채팅방 헤더 재배치 제안
## 목적
- 실제 공유채팅에서 사용하는 라이트 블루 테마를 유지한 채, 헤더를 방 선택과 알림센터 진입의 허브로 재구성한다.
- 채팅방 제목 또는 아이콘 선택 시 필터와 채팅방 목록을 함께 노출하고, 헤더 손잡이를 아래로 내리면 iOS 알림센터 같은 요약 대시보드와 알림 피드를 보여준다.
- 기존 기능은 유지하되, 설정/최소화/닫기 같은 툴 액션은 헤더 집중도를 해치지 않도록 보조 영역으로 정리한다.
## 현재 테마 확인 기준
- 공유채팅 카드 바디는 `#edf3fb → #e4edf8` 계열 세로 그라데이션과 얕은 inset border, soft shadow를 사용한다.
- 헤더 액션은 흰색 pill 버튼 위에 `#2563eb` 아이콘 포인트를 두고 hover 시 더 밝은 블루 계열로 반응한다.
- 헤더 배경은 반투명 흰색보다 한 단계 채도 낮은 블루톤이며 blur가 들어간다.
- 방 상태/요청 상태는 회색, 파랑, 초록, 빨강 계열 pill로 구분한다.
## 제안 방향 공통 원칙
- 헤더 1행은 현재 방 인지와 이동, 헤더 2행은 상태/필터/알림센터 진입으로 역할을 명확히 분리한다.
- 방 목록은 제목 또는 아이콘을 누르는 행위 하나로 열리도록 통합하고, 목록 안에서 필터 칩과 최근 대화 프리뷰를 동시에 보여준다.
- 알림센터는 현재 방 전용이 아니라 전체 채팅방과 apps 알림을 함께 집계한다.
- 손잡이 드래그는 모달보다 시스템 오버레이처럼 느껴지게 하고, 상단에는 다시보드형 요약 카드를 고정한다.
## 제안안 구성
### A안 Capsule Rail
- 제목 캡슐 자체가 방 목록 트리거다.
- 헤더 중앙 손잡이를 내려 알림센터를 여는 패턴으로 가장 직관적이다.
- 알림센터 상단은 처리중, 새 답변, apps 경고, 캘린더성 일정 같은 4분할 다시보드다.
### B안 Split Status Bar
- 좌측은 방 정보, 우측은 앱 알림과 빠른 전환 상태를 모은 split bar 구조다.
- 필터를 헤더 하단의 segment row로 남겨 자주 쓰는 상태 전환을 더 빠르게 한다.
- 데스크톱 확장성은 좋지만 모바일에서는 약간 더 촘촘해질 수 있다.
### C안 Focus Stack
- 방 아이콘과 제목을 한 덩어리 hero chip으로 키워 현재 방 인지를 강화한다.
- 알림센터 대시보드 카드를 더 크게 두고, 현재 처리중 요청 중심의 집중도를 높인다.
- 정보량이 많을 때보다 “현재 작업 집중” 상황에 적합하다.
## 유지되어야 할 기존 기능
- 방 이동
- 필터 전환
- 설정
- 최소화
- 닫기
- 현재 연결 상태 표시
- 현재 방 상태 요약
## 알림센터 추천 콘텐츠
- 상단 고정 다시보드: 처리중 요청 수, 읽지 않은 다른 채팅방, apps 경고/완료, 오늘 일정 또는 예약 작업
- 중단: 현재 방 진행 카드와 다른 방 새 답변 카드 혼합 피드
- 하단: 앱별 알림 묶음, 빠른 액션, 전체 읽음/필터 토글
## 검토 포인트
- 방 목록과 알림센터를 둘 다 헤더에 얹되 탭 충돌 없이 한 손 조작이 가능한지
- 모바일에서 드래그 손잡이와 브라우저 스크롤 제스처가 충돌하지 않는지
- 현재 존재하는 설정/최소화/닫기 액션을 보조 영역으로 빼도 발견 가능성이 유지되는지

View File

@@ -12,6 +12,7 @@ import { isPreviewRuntime } from './previewRuntime';
export function AppShell() {
return (
<Routes>
<Route path="/shares/:token" element={<ChatSharePage />} />
<Route path="/chat-share/:token" element={<ChatSharePage />} />
<Route path="/chat/share/:token" element={<ChatSharePage />} />
<Route path="/" element={<MainLayout />}>

View File

@@ -257,6 +257,9 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat'])
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds &&
left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds &&
left.receiveRoomNotifications === right.receiveRoomNotifications &&
left.guidePwaInstallForNotifications === right.guidePwaInstallForNotifications &&
left.guideWebPushPermission === right.guideWebPushPermission &&
left.guideWebPushRegistration === right.guideWebPushRegistration &&
left.restartReservationCompletionDelaySeconds === right.restartReservationCompletionDelaySeconds
);
}
@@ -284,6 +287,18 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
changedLabels.push('채팅방 알림 수신');
}
if (saved.guidePwaInstallForNotifications !== draft.guidePwaInstallForNotifications) {
changedLabels.push('PWA 설치 유도');
}
if (saved.guideWebPushPermission !== draft.guideWebPushPermission) {
changedLabels.push('웹푸시 권한 유도');
}
if (saved.guideWebPushRegistration !== draft.guideWebPushRegistration) {
changedLabels.push('웹푸시 등록 유도');
}
if (saved.restartReservationCompletionDelaySeconds !== draft.restartReservationCompletionDelaySeconds) {
changedLabels.push('재기동 예약 자동 실행 대기 시간');
}
@@ -3522,8 +3537,8 @@ export function MainHeader({
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
description={
chatSettingsDirty
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}`
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}`
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, PWA 유도 ${appConfigDraft.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도 ${appConfigDraft.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도 ${appConfigDraft.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}`
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, PWA 유도는 ${appConfig.chat.guidePwaInstallForNotifications ? '사용' : '끔'}, 권한 유도는 ${appConfig.chat.guideWebPushPermission ? '사용' : '끔'}, 등록 유도는 ${appConfig.chat.guideWebPushRegistration ? '사용' : '끔'}, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}`
}
/>
@@ -3592,6 +3607,66 @@ export function MainHeader({
</Paragraph>
</div>
<div>
<Checkbox
checked={appConfigDraft.chat.guidePwaInstallForNotifications}
onChange={(event) => {
setAppConfigDraft((current) => ({
...current,
chat: {
...current.chat,
guidePwaInstallForNotifications: event.target.checked,
},
}));
}}
>
PWA
</Checkbox>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
.
</Paragraph>
</div>
<div>
<Checkbox
checked={appConfigDraft.chat.guideWebPushPermission}
onChange={(event) => {
setAppConfigDraft((current) => ({
...current,
chat: {
...current.chat,
guideWebPushPermission: event.target.checked,
},
}));
}}
>
</Checkbox>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
.
</Paragraph>
</div>
<div>
<Checkbox
checked={appConfigDraft.chat.guideWebPushRegistration}
onChange={(event) => {
setAppConfigDraft((current) => ({
...current,
chat: {
...current.chat,
guideWebPushRegistration: event.target.checked,
},
}));
}}
>
</Checkbox>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
.
</Paragraph>
</div>
<div>
<Text strong>Codex Live ()</Text>
<Paragraph type="secondary">Codex Live 1 .</Paragraph>

View File

@@ -7,7 +7,6 @@ import {
saveAppConfigToServer,
type AppConfig,
type PlanCostTimeUnit,
type WeeklyScheduleDay,
} from './appConfig';
import './SharedAppSettingsPage.css';

View File

@@ -22,6 +22,9 @@ export type AppConfig = {
codexLiveMaxExecutionSeconds: number;
codexLiveIdleTimeoutSeconds: number;
receiveRoomNotifications: boolean;
guidePwaInstallForNotifications: boolean;
guideWebPushPermission: boolean;
guideWebPushRegistration: boolean;
restartReservationCompletionDelaySeconds: number;
};
automation: {
@@ -78,6 +81,9 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
guidePwaInstallForNotifications: true,
guideWebPushPermission: true,
guideWebPushRegistration: true,
restartReservationCompletionDelaySeconds: 10,
},
automation: {
@@ -311,6 +317,18 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
chat?.receiveRoomNotifications,
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
),
guidePwaInstallForNotifications: normalizeBooleanValue(
chat?.guidePwaInstallForNotifications,
DEFAULT_APP_CONFIG.chat.guidePwaInstallForNotifications,
),
guideWebPushPermission: normalizeBooleanValue(
chat?.guideWebPushPermission,
DEFAULT_APP_CONFIG.chat.guideWebPushPermission,
),
guideWebPushRegistration: normalizeBooleanValue(
chat?.guideWebPushRegistration,
DEFAULT_APP_CONFIG.chat.guideWebPushRegistration,
),
restartReservationCompletionDelaySeconds: normalizeRestartReservationCompletionDelaySeconds(
chat?.restartReservationCompletionDelaySeconds,
DEFAULT_APP_CONFIG.chat.restartReservationCompletionDelaySeconds,

View File

@@ -80,6 +80,7 @@ export type PromptSubmitPayload = {
const PROMPT_FREE_TEXT_ONLY_LABEL = '선택 없이 기타 요청';
const PROMPT_PARENT_QUESTION_MAX_LENGTH = 400;
const PROMPT_PARENT_QUESTION_PREVIEW_MAX_LENGTH = 140;
const PROMPT_PREVIEW_CONTENT_CACHE = new Map<string, { content: string | null; contentType: string | null }>();
function buildOptionSelectionText(options: PromptOption[], selectedValues: string[]) {
const selectedOptions = options.filter((option) => selectedValues.includes(option.value));
@@ -640,20 +641,23 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState('');
const normalizedPreviewUrl = resolvePromptPreviewUrl(preview?.url);
const previewContent = preview?.content ?? null;
const previewType = preview?.type ?? null;
const hasPreview = Boolean(preview);
useEffect(() => {
setRemoteContent(preview?.content ?? null);
setRemoteContent(previewContent);
setRemoteContentType(null);
setLoadError('');
const shouldFetchTextPreview =
preview?.type === 'markdown' || preview?.type === 'html';
previewType === 'markdown' || previewType === 'html';
const shouldInspectResourcePreview =
preview?.type === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
previewType === 'resource' && normalizedPreviewUrl && canRenderFramePreview(normalizedPreviewUrl);
if (
!preview ||
preview.content ||
!hasPreview ||
previewContent ||
!normalizedPreviewUrl ||
(!shouldFetchTextPreview && !shouldInspectResourcePreview)
) {
@@ -661,7 +665,17 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
return;
}
const cachedPreview = PROMPT_PREVIEW_CONTENT_CACHE.get(`${previewType ?? 'unknown'}::${normalizedPreviewUrl}`);
if (cachedPreview) {
setRemoteContent(cachedPreview.content);
setRemoteContentType(cachedPreview.contentType);
setIsLoading(false);
return;
}
const controller = new AbortController();
let resolvedContentType: string | null = null;
setIsLoading(true);
void fetch(normalizedPreviewUrl, {
@@ -674,6 +688,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
}
const contentType = response.headers.get('content-type');
resolvedContentType = contentType;
if (!controller.signal.aborted) {
setRemoteContentType(contentType);
@@ -687,6 +702,10 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
})
.then((text) => {
if (!controller.signal.aborted) {
PROMPT_PREVIEW_CONTENT_CACHE.set(`${previewType ?? 'unknown'}::${normalizedPreviewUrl}`, {
content: text,
contentType: resolvedContentType,
});
setRemoteContent(text);
}
})
@@ -704,7 +723,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) {
});
return () => controller.abort();
}, [normalizedPreviewUrl, preview]);
}, [hasPreview, normalizedPreviewUrl, previewContent, previewType]);
return { remoteContent, remoteContentType, isLoading, loadError };
}
@@ -872,7 +891,7 @@ function PromptPreviewCard({
useEffect(() => {
onPreviewViewed?.(option);
}, [onPreviewViewed, option]);
}, [normalizedPreviewUrl, onPreviewViewed, option.label, option.value]);
const handleSharePreview = (event: ReactKeyboardEvent | ReactMouseEvent<HTMLElement>) => {
event.stopPropagation();
@@ -1241,18 +1260,25 @@ export function ChatPromptCard({
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => {
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
const emitSelectionChange = (nextSelections: Record<string, PromptStepDraftSelection>) => {
if (!onSelectionChange) {
const selectionChangeHandler = onSelectionChangeRef.current;
if (!selectionChangeHandler) {
return;
}
if (isLocked) {
onSelectionChange(null);
selectionChangeHandler(null);
return;
}
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
selectionChangeHandler(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
};
useEffect(() => {
@@ -1287,17 +1313,16 @@ export function ChatPromptCard({
return;
}
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments));
}, [attachments, isLocked, onSelectionChange, stepSelections, target]);
emitSelectionChange(stepSelections);
}, [attachments, isLocked, stepSelections]);
useEffect(() => {
if (!expandedOption?.preview) {
return;
}
const previewUrl = resolvePromptPreviewUrl(expandedOption.preview.url);
onPreviewViewed?.(previewUrl || null);
}, [expandedOption, onPreviewViewed]);
onPreviewViewed?.(expandedOptionPreviewUrl || null);
}, [expandedOption?.value, expandedOptionPreviewUrl, onPreviewViewed]);
const handleShareExpandedPreview = () => {
if (!expandedOption?.preview) {
@@ -1382,7 +1407,6 @@ export function ChatPromptCard({
...current,
[activeStep.key]: nextSelection,
};
emitSelectionChange(nextSelections);
return nextSelections;
});
};

View File

@@ -6,6 +6,83 @@ const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
function normalizeUrlFragmentValue(value: string) {
const normalized = String(value ?? '').trim().replace(/^#+/, '');
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewPathHash(value: string) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
const isRootRelative = normalized.startsWith('/');
try {
const parsed = new URL(normalized, 'https://local.invalid');
const segments = parsed.pathname.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0 || parsed.hash) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = encodeURIComponent(fileName);
parsed.pathname = segments.join('/');
parsed.hash = fragment;
if (isAbsoluteUrl) {
return parsed.toString();
}
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
} catch {
return normalized;
}
}
function normalizeResourceManagerPathSegment(segment: string) {
const normalized = String(segment ?? '').trim();
@@ -21,8 +98,11 @@ function normalizeResourceManagerPathSegment(segment: string) {
}
function buildResourceManagerPreviewPath(value: string) {
const normalized = String(value ?? '').trim().replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const normalized = normalizePreviewPathHash(String(value ?? '').trim().replace(/\\/g, '/'));
const hashIndex = normalized.indexOf('#');
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = String(matchedResourcePath ?? '').trim().replace(/^\/+/, '');
if (!resourcePath) {
@@ -41,7 +121,7 @@ function buildResourceManagerPreviewPath(value: string) {
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
}
function resolveLegacyExternalImageUrl(value: string) {
@@ -113,17 +193,17 @@ function extractKnownPreviewPath(value: string) {
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return pathname;
return normalizePreviewPathHash(pathname);
}
return normalized;
} catch {
return extractEmbeddedResourcePath(normalized);
return normalizePreviewPathHash(extractEmbeddedResourcePath(normalized));
}
}
export function normalizeChatResourceUrl(value: string) {
const normalized = extractKnownPreviewPath(value);
const normalized = normalizePreviewPathHash(extractKnownPreviewPath(value));
const legacyExternalImageUrl = resolveLegacyExternalImageUrl(normalized);
if (legacyExternalImageUrl) {

View File

@@ -51,9 +51,89 @@ function normalizeResourceManagerPathSegment(segment: string) {
}
}
function normalizeUrlFragmentValue(value: string) {
const normalized = normalizeText(value).replace(/^#+/, '');
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewPathHash(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
const isRootRelative = normalized.startsWith('/');
try {
const parsed = new URL(normalized, 'https://local.invalid');
const segments = parsed.pathname.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0 || parsed.hash) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = encodeURIComponent(fileName);
parsed.pathname = segments.join('/');
parsed.hash = fragment;
if (isAbsoluteUrl) {
return parsed.toString();
}
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
} catch {
return normalized;
}
}
function buildResourceManagerPreviewUrl(value: string) {
const normalized = normalizeText(value).replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
const hashIndex = normalized.indexOf('#');
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
if (!resourcePath) {
@@ -72,7 +152,7 @@ function buildResourceManagerPreviewUrl(value: string) {
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
}
function extractKnownPreviewPath(value: string) {
@@ -83,11 +163,11 @@ function extractKnownPreviewPath(value: string) {
}
try {
const parsed = new URL(normalized);
const parsed = new URL(normalized, 'https://local.invalid');
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return pathname;
return normalizePreviewPathHash(pathname);
}
return normalized;

View File

@@ -903,12 +903,10 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
export async function sendClientNotification(payload: ClientNotificationPayload) {
const notificationData = withCurrentAppOriginMetadata(payload.data);
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
return request<ClientNotificationSendResult>('/notifications/send', {
method: 'POST',
body: JSON.stringify({
...payload,
targetDeviceIds,
data: notificationData,
}),
});

View File

@@ -337,7 +337,7 @@ function readStoredShareLastRoomByToken() {
}
try {
const raw = window.localStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
const raw = window.sessionStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
if (!raw) {
return {} as Record<string, string>;
@@ -390,7 +390,48 @@ function writeStoredShareLastRoomSessionId(token: string, sessionId: string | nu
delete nextMap[normalizedToken];
}
window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap));
window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap));
}
function readShareRoomSessionIdFromLocation() {
if (typeof window === 'undefined') {
return '';
}
return new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
}
function writeShareRoomSessionIdToLocation(roomSessionId: string | null, mode: 'push' | 'replace' = 'replace') {
if (typeof window === 'undefined') {
return;
}
const normalizedSessionId = String(roomSessionId ?? '').trim();
const nextUrl = new URL(window.location.href);
const currentSessionId = nextUrl.searchParams.get('roomSessionId')?.trim() || '';
if (normalizedSessionId) {
if (currentSessionId === normalizedSessionId) {
return;
}
nextUrl.searchParams.set('roomSessionId', normalizedSessionId);
} else {
if (!currentSessionId) {
return;
}
nextUrl.searchParams.delete('roomSessionId');
}
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
if (mode === 'push') {
window.history.pushState(window.history.state, '', nextPath);
return;
}
window.history.replaceState(window.history.state, '', nextPath);
}
function getClientNotificationPermission(): ClientNotificationPermissionState {
@@ -3557,7 +3598,7 @@ export function ChatSharePage() {
return '';
}
const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
if (urlRoomSessionId) {
return urlRoomSessionId;
@@ -5278,13 +5319,31 @@ export function ChatSharePage() {
return;
}
const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
const restoredRoomSessionId = urlRoomSessionId || readStoredShareLastRoomSessionId(normalizedToken);
requestedRoomSessionIdRef.current = restoredRoomSessionId;
setRequestedRoomSessionId(restoredRoomSessionId);
}, [normalizedToken]);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handlePopState = () => {
const nextRoomSessionId = readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken);
requestedRoomSessionIdRef.current = nextRoomSessionId;
setRequestedRoomSessionId(nextRoomSessionId);
setIsShareRoomListVisible(false);
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [normalizedToken]);
useEffect(() => {
if (!normalizedToken || !hasSnapshotRef.current) {
return;
@@ -5329,22 +5388,10 @@ export function ChatSharePage() {
writeStoredShareLastRoomSessionId(normalizedToken, persistedRoomSessionId);
}, [normalizedToken, selectedShareRoomSessionId, shareRooms]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const nextUrl = new URL(window.location.href);
const roomSessionId = shareRooms.some((room) => room.sessionId === requestedRoomSessionId)
? requestedRoomSessionId
: activeShareRoomSessionId;
if (roomSessionId) {
nextUrl.searchParams.set('roomSessionId', roomSessionId);
} else {
nextUrl.searchParams.delete('roomSessionId');
}
window.history.replaceState(null, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
writeShareRoomSessionIdToLocation(roomSessionId || null, 'replace');
}, [activeShareRoomSessionId, requestedRoomSessionId, shareRooms]);
const handleUnlockShare = useCallback(async (inputPin?: string) => {
@@ -5826,9 +5873,12 @@ export function ChatSharePage() {
roomSwitchSequenceRef.current += 1;
setIsRoomSwitching(true);
requestedRoomSessionIdRef.current = normalizedSessionId;
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
setRequestedRoomSessionId(normalizedSessionId);
setIsShareRoomListVisible(false);
}, [selectedShareRoomSessionId]);
}, [normalizedToken, selectedShareRoomSessionId]);
const handleCreateShareRoom = useCallback(async () => {
if (!normalizedToken || isCreatingRoom) {
@@ -5871,6 +5921,9 @@ export function ChatSharePage() {
? current
: [...current, createdRoom]
));
requestedRoomSessionIdRef.current = createdRoom.sessionId;
writeStoredShareLastRoomSessionId(normalizedToken, createdRoom.sessionId);
writeShareRoomSessionIdToLocation(createdRoom.sessionId, 'push');
setRequestedRoomSessionId(createdRoom.sessionId);
setDraftText('');
setComposerAttachments([]);

View File

@@ -79,6 +79,7 @@ type SuccessLogRow = {
clientId: string;
ownerType: 'client' | 'shared-token';
ownerId: string;
ownerLabel?: string | null;
alertTitle: string;
ticketTitle: string;
eventDateTime: string;
@@ -641,9 +642,15 @@ function formatScopeIdentifierLabel(value: string | null | undefined) {
return formatClientIdLabel(value?.trim() ?? '');
}
function formatOwnershipLabel(ownerType: 'client' | 'shared-token', ownerId: string | null | undefined, clientId: string) {
function formatOwnershipLabel(
ownerType: 'client' | 'shared-token',
ownerId: string | null | undefined,
clientId: string,
ownerLabel?: string | null,
) {
if (ownerType === 'shared-token') {
return `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
const normalizedOwnerLabel = ownerLabel?.trim() ?? '';
return normalizedOwnerLabel || `토큰 ${formatScopeIdentifierLabel(ownerId)}`;
}
return `기기 ${formatClientIdLabel(clientId)}`;
@@ -825,6 +832,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
ownerLabel: item.ownerLabel ?? alert?.ownerLabel ?? null,
alertTitle: alert.title,
ticketTitle: result.title,
eventDateTime: result.eventDateTime,
@@ -865,7 +873,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
[selectedSuccessRows],
);
const selectableSuccessRows = useMemo(
() => (accessScope === 'shared-token' ? successRows : successRows.filter((item) => item.clientId === clientId)),
() => (accessScope === 'client' ? successRows.filter((item) => item.clientId === clientId) : successRows),
[accessScope, clientId, successRows],
);
const isAllSuccessRowsSelected = selectableSuccessRows.length > 0 && selectedSuccessRowIds.length === selectableSuccessRows.length;
@@ -882,7 +890,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
}
if (accessScope === 'all') {
return false;
return true;
}
return targetClientId === clientId;
@@ -976,13 +984,17 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
};
const appendLog = (
item: Pick<AlertLogItem, 'alertId' | 'alertTitle' | 'action' | 'status' | 'message' | 'detail'>,
item: Pick<AlertLogItem, 'alertId' | 'alertTitle' | 'action' | 'status' | 'message' | 'detail'>
& Partial<Pick<AlertLogItem, 'ownerType' | 'ownerId' | 'ownerLabel'>>,
) => {
setLogs((previous) => [
{
id: createId('log'),
clientId,
createdAt: new Date().toISOString(),
ownerType: 'client',
ownerId: clientId,
ownerLabel: null,
...item,
},
...previous,
@@ -1027,6 +1039,9 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
status: 'error',
message: error instanceof Error ? error.message : '즉시 실행에 실패했습니다.',
detail: buildAlertSummary(alert),
ownerType: alert.ownerType,
ownerId: alert.ownerId,
ownerLabel: alert.ownerLabel ?? null,
});
messageApi.error(error instanceof Error ? error.message : '즉시 실행에 실패했습니다.');
} finally {
@@ -1618,7 +1633,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
<strong>{item.ticketTitle}</strong>
<div className="baseball-ticket-bay-app__success-item-top-tags">
{isViewingAllClients || isSharedTokenScope ? (
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}</Tag>
<Tag bordered={false}>{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}</Tag>
) : null}
<Tag bordered={false} color="green">
@@ -1651,7 +1666,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
</Tag>
{isViewingAllClients || isSharedTokenScope ? (
<Tag bordered={false}>
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId)}
{formatOwnershipLabel(selectedSuccessRow.ownerType, selectedSuccessRow.ownerId, selectedSuccessRow.clientId, selectedSuccessRow.ownerLabel)}
</Tag>
) : null}
<Button
@@ -2043,7 +2058,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
</div>
{isViewingAllClients || isSharedTokenScope ? (
<div className="baseball-ticket-bay-app__scope-note">
{isSharedTokenScope ? '현재 공유 토큰 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 수정과 삭제는 현재 기기 항목만 가능합니다.'}
{isSharedTokenScope ? '현재 공유 리소스 별명 기준으로 목록을 표시합니다.' : '등록 토큰으로 전체 기기 목록을 보고 있습니다. 리소스 별명으로 확인하고 수정과 삭제도 바로 할 수 있습니다.'}
</div>
) : null}
<div className="baseball-ticket-bay-app__items">
@@ -2063,7 +2078,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
<div className="baseball-ticket-bay-app__item-tags">
{isViewingAllClients || isSharedTokenScope ? (
<Tag bordered={false} color={isOwnedByCurrentClient ? 'blue' : 'default'}>
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}
</Tag>
) : null}
<Tag bordered={false} color={item.active ? 'green' : 'default'}>
@@ -2168,7 +2183,7 @@ export function BaseballTicketBayPlayAppView({ onBack, launchContext = 'direct',
<strong>{item.alertTitle}</strong>
{isViewingAllClients || isSharedTokenScope ? (
<span className="baseball-ticket-bay-app__log-client">
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId)}
{formatOwnershipLabel(item.ownerType, item.ownerId, item.clientId, item.ownerLabel)}
</span>
) : null}
</div>

View File

@@ -16,6 +16,7 @@ export type BaseballTicketBayAlertItem = {
clientId: string;
ownerType: 'client' | 'shared-token';
ownerId: string;
ownerLabel?: string | null;
title: string;
eventDate: string;
team: string;
@@ -92,6 +93,7 @@ export type BaseballTicketBayAlertLogItem = {
clientId: string;
ownerType: 'client' | 'shared-token';
ownerId: string;
ownerLabel?: string | null;
alertId: string | null;
alertTitle: string;
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
@@ -104,7 +106,7 @@ export type BaseballTicketBayAlertLogItem = {
type BaseballTicketBayAlertMutation = Omit<
BaseballTicketBayAlertItem,
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
'id' | 'clientId' | 'ownerType' | 'ownerId' | 'ownerLabel' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
>;
type BaseballTicketBayAlertsResponse = {